mirror of
https://github.com/novatic14/MANPADS-System-Launcher-and-Rocket.git
synced 2026-03-28 00:15:38 +00:00
yyup
works
This commit is contained in:
150
Firmware/Calibration & Test Code/Fin Calibration.txt
Normal file
150
Firmware/Calibration & Test Code/Fin Calibration.txt
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* ESP32 Rocket Fin Calibrator (FIXED)
|
||||
* * MAPPING:
|
||||
* A -> Servo 27 (UP)
|
||||
* B -> Servo 14 (DOWN)
|
||||
* C -> Servo 26 (RIGHT)
|
||||
* D -> Servo 25 (LEFT)
|
||||
*/
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <ESP32Servo.h>
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// CONFIGURATION
|
||||
// -----------------------------------------------------------
|
||||
const int NUM_SERVOS = 4;
|
||||
|
||||
// Pin Definitions
|
||||
const int PIN_UP = 27;
|
||||
const int PIN_DOWN = 14;
|
||||
const int PIN_RIGHT = 26;
|
||||
const int PIN_LEFT = 25;
|
||||
|
||||
// Arrays for easy indexing: 0=UP, 1=DOWN, 2=RIGHT, 3=LEFT
|
||||
int servoPins[NUM_SERVOS] = {PIN_UP, PIN_DOWN, PIN_RIGHT, PIN_LEFT};
|
||||
String servoNames[NUM_SERVOS] = {"UP (27)", "DOWN (14)", "RIGHT (26)", "LEFT (25)"};
|
||||
|
||||
// This array holds the live center angle for each servo
|
||||
int centerAngles[NUM_SERVOS] = {90, 90, 90, 90};
|
||||
|
||||
// Track which servo is currently selected (Default to 0 / UP)
|
||||
int activeIndex = 0;
|
||||
|
||||
Servo servos[NUM_SERVOS];
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// FUNCTION PROTOTYPES (This fixes the error!)
|
||||
// -----------------------------------------------------------
|
||||
void printInstructions();
|
||||
void printSelection();
|
||||
void changeAngle(int amount);
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// SETUP
|
||||
// -----------------------------------------------------------
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
while (!Serial) delay(10);
|
||||
|
||||
// Allocate timers
|
||||
ESP32PWM::allocateTimer(0);
|
||||
ESP32PWM::allocateTimer(1);
|
||||
ESP32PWM::allocateTimer(2);
|
||||
ESP32PWM::allocateTimer(3);
|
||||
|
||||
Serial.println("--- Rocket Fin Calibrator Started ---");
|
||||
|
||||
// Attach all servos and move to initial 90
|
||||
for (int i = 0; i < NUM_SERVOS; i++) {
|
||||
servos[i].setPeriodHertz(50);
|
||||
servos[i].attach(servoPins[i]);
|
||||
servos[i].write(centerAngles[i]);
|
||||
}
|
||||
|
||||
printInstructions();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// LOOP
|
||||
// -----------------------------------------------------------
|
||||
void loop() {
|
||||
if (Serial.available() > 0) {
|
||||
char cmd = Serial.read();
|
||||
|
||||
// Ignore newline/carriage return characters
|
||||
if (cmd == '\n' || cmd == '\r') return;
|
||||
|
||||
// Convert lowercase a,b,c,d to uppercase
|
||||
cmd = toupper(cmd);
|
||||
|
||||
// --- SELECTION LOGIC ---
|
||||
if (cmd == 'A') {
|
||||
activeIndex = 0;
|
||||
printSelection();
|
||||
}
|
||||
else if (cmd == 'B') {
|
||||
activeIndex = 1;
|
||||
printSelection();
|
||||
}
|
||||
else if (cmd == 'C') {
|
||||
activeIndex = 2;
|
||||
printSelection();
|
||||
}
|
||||
else if (cmd == 'D') {
|
||||
activeIndex = 3;
|
||||
printSelection();
|
||||
}
|
||||
// --- ADJUSTMENT LOGIC ---
|
||||
else if (cmd == '+') {
|
||||
changeAngle(1);
|
||||
}
|
||||
else if (cmd == '-') {
|
||||
changeAngle(-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// HELPER FUNCTIONS
|
||||
// -----------------------------------------------------------
|
||||
|
||||
void changeAngle(int amount) {
|
||||
// Update the angle for the CURRENTLY selected servo only
|
||||
centerAngles[activeIndex] += amount;
|
||||
|
||||
// Safety limits (0 to 180)
|
||||
if (centerAngles[activeIndex] > 180) centerAngles[activeIndex] = 180;
|
||||
if (centerAngles[activeIndex] < 0) centerAngles[activeIndex] = 0;
|
||||
|
||||
// Move the servo
|
||||
servos[activeIndex].write(centerAngles[activeIndex]);
|
||||
|
||||
// Print Update
|
||||
Serial.print(">>> Adjusted ");
|
||||
Serial.print(servoNames[activeIndex]);
|
||||
Serial.print(" | New Center: ");
|
||||
Serial.println(centerAngles[activeIndex]);
|
||||
}
|
||||
|
||||
void printSelection() {
|
||||
Serial.print("\n>>> SELECTED: ");
|
||||
Serial.println(servoNames[activeIndex]);
|
||||
Serial.print(" Current Angle: ");
|
||||
Serial.println(centerAngles[activeIndex]);
|
||||
Serial.println(" Use '+' or '-' to adjust.");
|
||||
}
|
||||
|
||||
void printInstructions() {
|
||||
Serial.println("----------------------------------------");
|
||||
Serial.println("Type 'A' -> Select UP (27)");
|
||||
Serial.println("Type 'B' -> Select DOWN (14)");
|
||||
Serial.println("Type 'C' -> Select RIGHT (26)");
|
||||
Serial.println("Type 'D' -> Select LEFT (25)");
|
||||
Serial.println("----------------------------------------");
|
||||
Serial.println("Then use '+' or '-' to adjust that servo.");
|
||||
Serial.println("----------------------------------------");
|
||||
|
||||
// Select A by default on startup
|
||||
printSelection();
|
||||
}
|
||||
126
Firmware/Calibration & Test Code/I2Cworks.txt
Normal file
126
Firmware/Calibration & Test Code/I2Cworks.txt
Normal file
@@ -0,0 +1,126 @@
|
||||
#include <Arduino.h>
|
||||
#include <Wire.h>
|
||||
#include <Adafruit_BMP085.h>
|
||||
#include <QMC5883LCompass.h>
|
||||
|
||||
// Initialize sensor objects
|
||||
Adafruit_BMP085 bmp;
|
||||
QMC5883LCompass compass;
|
||||
|
||||
// Flags and variables
|
||||
bool bmp_working = false;
|
||||
const int I2C_SDA = 21;
|
||||
const int I2C_SCL = 22;
|
||||
|
||||
// Function to scan the bus and return number of devices
|
||||
int scanI2CBus() {
|
||||
Serial.println("--- Scanning I2C Bus ---");
|
||||
byte error, address;
|
||||
int nDevices = 0;
|
||||
|
||||
// A standard I2C scanner helps determine if the hardware is electrically connected
|
||||
for(address = 1; address < 127; address++ ) {
|
||||
Wire.beginTransmission(address);
|
||||
error = Wire.endTransmission();
|
||||
|
||||
if (error == 0) {
|
||||
Serial.print("Found device at 0x");
|
||||
if (address < 16) Serial.print("0");
|
||||
Serial.print(address, HEX);
|
||||
|
||||
// Identify common addresses
|
||||
if (address == 0x77) Serial.println(" (Likely BMP180)");
|
||||
else if (address == 0x0D) Serial.println(" (Likely QMC5883L)");
|
||||
else Serial.println(" (Unknown Device)");
|
||||
|
||||
nDevices++;
|
||||
} else if (error == 4) {
|
||||
Serial.print("Unknown error at address 0x");
|
||||
if (address < 16) Serial.print("0");
|
||||
Serial.println(address, HEX);
|
||||
}
|
||||
}
|
||||
return nDevices;
|
||||
}
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
delay(2000); // Give serial monitor time to connect
|
||||
|
||||
// Initialize I2C with stability settings
|
||||
// Default for ESP32 DevKit V1 is SDA=21, SCL=22
|
||||
Wire.begin(I2C_SDA, I2C_SCL);
|
||||
Wire.setClock(100000); // Standard 100kHz mode is safer for jumper wires
|
||||
Wire.setTimeOut(1000); // Timeout to prevent the CPU from hanging on a bad bus
|
||||
|
||||
int devicesFound = scanI2CBus();
|
||||
|
||||
if (devicesFound == 0) {
|
||||
Serial.println("ERROR: No I2C devices detected!");
|
||||
Serial.println("1. Check if 3.3V and GND are connected to both sensors.");
|
||||
Serial.println("2. Check if SDA is on Pin 21 and SCL is on Pin 22.");
|
||||
Serial.println("3. Ensure your soldering joints are making contact with the pads.");
|
||||
}
|
||||
|
||||
Serial.println("\n--- Initializing Sensor Libraries ---");
|
||||
|
||||
// BMP180 Initialization
|
||||
if (!bmp.begin()) {
|
||||
Serial.println("BMP180: FAILED to initialize library.");
|
||||
bmp_working = false;
|
||||
} else {
|
||||
Serial.println("BMP180: SUCCESS");
|
||||
bmp_working = true;
|
||||
}
|
||||
|
||||
// QMC5883L Initialization
|
||||
compass.init();
|
||||
compass.setSmoothing(10, true);
|
||||
Serial.println("QMC5883L: Initialized (Note: init() does not check for presence)");
|
||||
|
||||
Serial.println("----------------------------\n");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
Serial.println("========== SENSOR DATA ==========");
|
||||
|
||||
// 1. Process BMP180
|
||||
if (bmp_working) {
|
||||
float temp = bmp.readTemperature();
|
||||
// Check for logical errors (extreme values)
|
||||
if (temp < -40 || temp > 80) {
|
||||
Serial.println("[BMP180] Data Error: Reading out of range.");
|
||||
} else {
|
||||
Serial.print("[BMP180] Temp: "); Serial.print(temp);
|
||||
Serial.print(" C | Pressure: "); Serial.print(bmp.readPressure()); Serial.println(" Pa");
|
||||
}
|
||||
} else {
|
||||
Serial.println("[BMP180] OFFLINE (Initialization failed)");
|
||||
}
|
||||
|
||||
// 2. Process QMC5883L
|
||||
compass.read();
|
||||
int x = compass.getX();
|
||||
int y = compass.getY();
|
||||
int z = compass.getZ();
|
||||
|
||||
// If the compass returns all zeros, it is physically disconnected or the bus crashed
|
||||
if (x == 0 && y == 0 && z == 0) {
|
||||
Serial.println("[QMC5883L] OFFLINE (Returning all zeros)");
|
||||
|
||||
// Attempt emergency bus recovery if we lost communication
|
||||
Serial.println("Attempting I2C Bus Reset...");
|
||||
Wire.end();
|
||||
delay(100);
|
||||
Wire.begin(I2C_SDA, I2C_SCL);
|
||||
Wire.setClock(100000);
|
||||
} else {
|
||||
Serial.print("[QMC5883L] X: "); Serial.print(x);
|
||||
Serial.print(" | Y: "); Serial.print(y);
|
||||
Serial.print(" | Z: "); Serial.print(z);
|
||||
Serial.print(" | Heading: "); Serial.print(compass.getAzimuth()); Serial.println("°");
|
||||
}
|
||||
|
||||
Serial.println("=================================\n");
|
||||
delay(1000);
|
||||
}
|
||||
339
Firmware/Calibration & Test Code/ROLLstabilization.txt
Normal file
339
Firmware/Calibration & Test Code/ROLLstabilization.txt
Normal file
@@ -0,0 +1,339 @@
|
||||
/* =========================================================================
|
||||
* MPU6050 ROCKET STABILIZATION - ACCESS POINT (AP) VERSION
|
||||
* =========================================================================
|
||||
* CHANGES FROM PREVIOUS:
|
||||
* 1. WiFi Mode: Creates its own WiFi network (SoftAP)
|
||||
* 2. Addressing: Defaults to BROADCAST IP until it hears from a specific computer
|
||||
* 3. Discovery: Auto-detects the computer's IP when it receives a command
|
||||
* 4. PID Tuning: Added runtime PID tuning and saving to internal Flash (NVS)
|
||||
* ========================================================================= */
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <Wire.h>
|
||||
#include <Adafruit_MPU6050.h>
|
||||
#include <Adafruit_Sensor.h>
|
||||
#include <ESP32Servo.h>
|
||||
#include <WiFi.h>
|
||||
#include <WiFiUdp.h>
|
||||
#include <Preferences.h>
|
||||
|
||||
// --- WiFi / Network Configuration ---
|
||||
// The Rocket will CREATE this network
|
||||
const char* ssid = "RocketLink_Telemetry";
|
||||
const char* password = "rocketlaunch";
|
||||
|
||||
// Telemetry Destination
|
||||
// Initially set to BROADCAST so any connected computer can hear it immediately.
|
||||
// Once a command is received, this updates to the specific sender's IP.
|
||||
IPAddress remoteIp(192, 168, 4, 255);
|
||||
const int remotePort = 4444; // Port to send telemetry TO
|
||||
const int localPort = 4444; // Port to listen for commands ON
|
||||
|
||||
WiFiUDP udp;
|
||||
Preferences preferences;
|
||||
|
||||
// --- Hardware Pin Definitions ---
|
||||
const int LEFT_SERVO_PIN = 26;
|
||||
const int RIGHT_SERVO_PIN = 25;
|
||||
const int UP_SERVO_PIN = 27;
|
||||
const int DOWN_SERVO_PIN = 14;
|
||||
|
||||
// --- Servo Settings ---
|
||||
Servo leftServo;
|
||||
Servo rightServo;
|
||||
Servo upServo;
|
||||
Servo downServo;
|
||||
|
||||
const int LEFT_CENTER = 115;
|
||||
const int RIGHT_CENTER = 80;
|
||||
const int UP_CENTER = 80;
|
||||
const int DOWN_CENTER = 115;
|
||||
const int MAX_DEFLECTION = 15;
|
||||
|
||||
// --- PID & Stabilization ---
|
||||
Adafruit_MPU6050 mpu;
|
||||
float Kp = 0.5; // Default, overridden by preferences in setup
|
||||
float Kd = 0.2; // Default, overridden by preferences in setup
|
||||
float roll = 0;
|
||||
float gyroX_offset = 0;
|
||||
|
||||
// --- State Machine ---
|
||||
enum RocketState {
|
||||
STATE_IDLE,
|
||||
STATE_LAUNCHING,
|
||||
STATE_FLIGHT
|
||||
};
|
||||
|
||||
RocketState currentState = STATE_IDLE;
|
||||
|
||||
// --- Launch Detection Constants ---
|
||||
const float LAUNCH_THRESHOLD_G = 1.6;
|
||||
const float G_TO_MS2 = 9.80665;
|
||||
const float LAUNCH_THRESHOLD_MS2 = LAUNCH_THRESHOLD_G * G_TO_MS2;
|
||||
const int LAUNCH_DURATION_MS = 50;
|
||||
unsigned long launchTriggerStartTime = 0;
|
||||
|
||||
// --- Timing Variables ---
|
||||
unsigned long last_time;
|
||||
float dt;
|
||||
unsigned long lastStateMsgTime = 0;
|
||||
const int STATE_MSG_INTERVAL = 2000;
|
||||
|
||||
// --- Function Prototypes ---
|
||||
void setServosEnabled(bool enabled);
|
||||
void changeState(RocketState newState);
|
||||
void sendTelemetry(float roll, float rate, int out);
|
||||
void sendStateUpdate();
|
||||
void calibrateGyro();
|
||||
|
||||
void setup(void) {
|
||||
Serial.begin(115200);
|
||||
Wire.begin(21, 22);
|
||||
|
||||
// 1. Setup Servos
|
||||
ESP32PWM::allocateTimer(0);
|
||||
ESP32PWM::allocateTimer(1);
|
||||
ESP32PWM::allocateTimer(2);
|
||||
ESP32PWM::allocateTimer(3);
|
||||
|
||||
leftServo.setPeriodHertz(50);
|
||||
rightServo.setPeriodHertz(50);
|
||||
upServo.setPeriodHertz(50);
|
||||
downServo.setPeriodHertz(50);
|
||||
setServosEnabled(false);
|
||||
|
||||
// 2. Setup MPU6050
|
||||
if (!mpu.begin()) {
|
||||
Serial.println("Failed to find MPU6050 chip");
|
||||
while (1) { delay(10); }
|
||||
}
|
||||
Serial.println("MPU6050 Found!");
|
||||
mpu.setAccelerometerRange(MPU6050_RANGE_16_G);
|
||||
mpu.setGyroRange(MPU6050_RANGE_500_DEG);
|
||||
mpu.setFilterBandwidth(MPU6050_BAND_21_HZ);
|
||||
|
||||
// 3. Load Calibration and PID config
|
||||
preferences.begin("rocket_cfg", false);
|
||||
gyroX_offset = preferences.getFloat("gyro_bias", 0.0);
|
||||
Kp = preferences.getFloat("Kp", 0.5); // Load saved Kp, default to 0.5
|
||||
Kd = preferences.getFloat("Kd", 0.2); // Load saved Kd, default to 0.2
|
||||
|
||||
Serial.println("--- Saved Config Loaded ---");
|
||||
Serial.print("Gyro Bias: "); Serial.println(gyroX_offset);
|
||||
Serial.print("Kp: "); Serial.println(Kp);
|
||||
Serial.print("Kd: "); Serial.println(Kd);
|
||||
Serial.println("---------------------------");
|
||||
|
||||
// 4. Setup Access Point (AP)
|
||||
Serial.println("Starting Access Point...");
|
||||
WiFi.softAP(ssid, password);
|
||||
|
||||
// The default IP for ESP32 SoftAP is usually 192.168.4.1
|
||||
Serial.print("AP Created. Connect to: "); Serial.println(ssid);
|
||||
Serial.print("Rocket IP: "); Serial.println(WiFi.softAPIP());
|
||||
|
||||
udp.begin(localPort);
|
||||
|
||||
Serial.println("System Ready. Broadcasting Telemetry...");
|
||||
last_time = millis();
|
||||
}
|
||||
|
||||
void loop() {
|
||||
sensors_event_t a, g, temp;
|
||||
mpu.getEvent(&a, &g, &temp);
|
||||
|
||||
// Calculate Delta Time
|
||||
unsigned long current_time = millis();
|
||||
dt = (current_time - last_time) / 1000.0;
|
||||
last_time = current_time;
|
||||
|
||||
if (dt <= 0) return;
|
||||
|
||||
// --- Network: Receive Commands & Auto-Detect Remote IP ---
|
||||
int packetSize = udp.parsePacket();
|
||||
if (packetSize) {
|
||||
// CAPTURE SENDER IP: This enables 2-way comms without hardcoding
|
||||
IPAddress senderIp = udp.remoteIP();
|
||||
if (remoteIp != senderIp) {
|
||||
remoteIp = senderIp;
|
||||
Serial.print("Connected to Ground Station at: ");
|
||||
Serial.println(remoteIp);
|
||||
}
|
||||
|
||||
char packetBuffer[255];
|
||||
int len = udp.read(packetBuffer, 255);
|
||||
if (len > 0) packetBuffer[len] = 0;
|
||||
|
||||
String command = String(packetBuffer);
|
||||
command.trim();
|
||||
|
||||
// -- Parse Tuning Commands (Any state) --
|
||||
if (command.startsWith("PID,")) {
|
||||
// Expected format: PID,0.5,0.2
|
||||
int firstComma = command.indexOf(',');
|
||||
int secondComma = command.indexOf(',', firstComma + 1);
|
||||
|
||||
if (firstComma > 0 && secondComma > 0) {
|
||||
Kp = command.substring(firstComma + 1, secondComma).toFloat();
|
||||
Kd = command.substring(secondComma + 1).toFloat();
|
||||
|
||||
// Save to internal flash so it survives reboots
|
||||
preferences.putFloat("Kp", Kp);
|
||||
preferences.putFloat("Kd", Kd);
|
||||
|
||||
Serial.print("New PID Saved -> Kp: "); Serial.print(Kp);
|
||||
Serial.print(" Kd: "); Serial.println(Kd);
|
||||
|
||||
// Send updated state immediately so dashboard syncs
|
||||
sendStateUpdate();
|
||||
}
|
||||
}
|
||||
// -- Parse Mission Commands (Only in IDLE) --
|
||||
else if (currentState == STATE_IDLE) {
|
||||
if (command.equalsIgnoreCase("launch")) {
|
||||
changeState(STATE_LAUNCHING);
|
||||
}
|
||||
else if (command.equalsIgnoreCase("calibrate")) {
|
||||
Serial.println("Recalibration Command Received.");
|
||||
calibrateGyro();
|
||||
roll = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Network: Send State Heartbeat ---
|
||||
if (millis() - lastStateMsgTime > STATE_MSG_INTERVAL) {
|
||||
sendStateUpdate();
|
||||
lastStateMsgTime = millis();
|
||||
}
|
||||
|
||||
// --- Gyro Integration ---
|
||||
float raw_rate_rad = g.gyro.x - gyroX_offset;
|
||||
float rate_deg_s = raw_rate_rad * 180.0 / PI;
|
||||
roll += rate_deg_s * dt;
|
||||
|
||||
int currentServoOffset = 0;
|
||||
|
||||
// --- STATE MACHINE LOGIC ---
|
||||
switch (currentState) {
|
||||
case STATE_IDLE:
|
||||
break;
|
||||
|
||||
case STATE_LAUNCHING:
|
||||
{
|
||||
float accel_mag = sqrt(sq(a.acceleration.x) + sq(a.acceleration.y) + sq(a.acceleration.z));
|
||||
if (accel_mag > LAUNCH_THRESHOLD_MS2) {
|
||||
if (launchTriggerStartTime == 0) {
|
||||
launchTriggerStartTime = millis();
|
||||
} else if (millis() - launchTriggerStartTime > LAUNCH_DURATION_MS) {
|
||||
changeState(STATE_FLIGHT);
|
||||
}
|
||||
} else {
|
||||
launchTriggerStartTime = 0;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case STATE_FLIGHT:
|
||||
{
|
||||
float P_term = Kp * roll;
|
||||
float D_term = Kd * rate_deg_s;
|
||||
float output = P_term + D_term;
|
||||
|
||||
currentServoOffset = (int)output;
|
||||
currentServoOffset = constrain(currentServoOffset, -MAX_DEFLECTION, MAX_DEFLECTION);
|
||||
|
||||
leftServo.write(LEFT_CENTER + currentServoOffset);
|
||||
rightServo.write(RIGHT_CENTER + currentServoOffset);
|
||||
upServo.write(UP_CENTER + currentServoOffset);
|
||||
downServo.write(DOWN_CENTER + currentServoOffset);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// --- TELEMETRY ---
|
||||
sendTelemetry(roll, rate_deg_s, currentServoOffset);
|
||||
|
||||
delay(5);
|
||||
}
|
||||
|
||||
// --- Helper Functions ---
|
||||
|
||||
void calibrateGyro() {
|
||||
Serial.println("Calibrating Gyro... KEEP STILL");
|
||||
delay(1000);
|
||||
float sumX = 0;
|
||||
int calibration_samples = 200;
|
||||
for (int i = 0; i < calibration_samples; i++) {
|
||||
sensors_event_t a, g, temp;
|
||||
mpu.getEvent(&a, &g, &temp);
|
||||
sumX += g.gyro.x;
|
||||
delay(5);
|
||||
}
|
||||
gyroX_offset = sumX / calibration_samples;
|
||||
preferences.putFloat("gyro_bias", gyroX_offset);
|
||||
Serial.print("New Gyro Offset Saved: "); Serial.println(gyroX_offset);
|
||||
}
|
||||
|
||||
void changeState(RocketState newState) {
|
||||
currentState = newState;
|
||||
switch (currentState) {
|
||||
case STATE_IDLE:
|
||||
setServosEnabled(false);
|
||||
break;
|
||||
case STATE_LAUNCHING:
|
||||
setServosEnabled(false);
|
||||
launchTriggerStartTime = 0;
|
||||
roll = 0;
|
||||
break;
|
||||
case STATE_FLIGHT:
|
||||
setServosEnabled(true);
|
||||
break;
|
||||
}
|
||||
sendStateUpdate();
|
||||
lastStateMsgTime = millis();
|
||||
}
|
||||
|
||||
void setServosEnabled(bool enabled) {
|
||||
if (enabled) {
|
||||
if (!leftServo.attached()) leftServo.attach(LEFT_SERVO_PIN, 500, 2400);
|
||||
if (!rightServo.attached()) rightServo.attach(RIGHT_SERVO_PIN, 500, 2400);
|
||||
if (!upServo.attached()) upServo.attach(UP_SERVO_PIN, 500, 2400);
|
||||
if (!downServo.attached()) downServo.attach(DOWN_SERVO_PIN, 500, 2400);
|
||||
} else {
|
||||
if (leftServo.attached()) leftServo.detach();
|
||||
if (rightServo.attached()) rightServo.detach();
|
||||
if (upServo.attached()) upServo.detach();
|
||||
if (downServo.attached()) downServo.detach();
|
||||
}
|
||||
}
|
||||
|
||||
void sendStateUpdate() {
|
||||
// Send to the current remoteIp (Starts as broadcast .255, then becomes specific)
|
||||
// Modified to include Kp and Kd so dashboard stays in sync
|
||||
udp.beginPacket(remoteIp, remotePort);
|
||||
udp.print("STATUS:");
|
||||
switch (currentState) {
|
||||
case STATE_IDLE: udp.print("IDLE"); break;
|
||||
case STATE_LAUNCHING: udp.print("LAUNCHING"); break;
|
||||
case STATE_FLIGHT: udp.print("FLIGHT"); break;
|
||||
}
|
||||
udp.print(",");
|
||||
udp.print(Kp);
|
||||
udp.print(",");
|
||||
udp.print(Kd);
|
||||
udp.endPacket();
|
||||
}
|
||||
|
||||
void sendTelemetry(float roll, float rate, int out) {
|
||||
udp.beginPacket(remoteIp, remotePort);
|
||||
udp.print("T,");
|
||||
udp.print(millis());
|
||||
udp.print(",");
|
||||
udp.print(roll, 1);
|
||||
udp.print(",");
|
||||
udp.print(rate, 1);
|
||||
udp.print(",");
|
||||
udp.print(out);
|
||||
udp.endPacket();
|
||||
}
|
||||
411
Firmware/DASHBOARDpython.txt
Normal file
411
Firmware/DASHBOARDpython.txt
Normal file
@@ -0,0 +1,411 @@
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, filedialog, messagebox
|
||||
import socket
|
||||
import threading
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.animation import FuncAnimation
|
||||
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
|
||||
from collections import deque
|
||||
import time
|
||||
import numpy as np
|
||||
|
||||
# --- Configuration ---
|
||||
UDP_IP = "0.0.0.0"
|
||||
UDP_PORT = 4444
|
||||
BUFFER_SIZE = 1024
|
||||
VIEW_POINTS = 100
|
||||
RATE_SCALE = 0.25
|
||||
|
||||
LAUNCHER_GATEWAY = "192.168.4.1"
|
||||
|
||||
class TelemetryApp:
|
||||
def __init__(self, root):
|
||||
self.root = root
|
||||
self.root.title("Rocket Telemetry Dashboard - Ground Control")
|
||||
self.root.geometry("1100x850")
|
||||
|
||||
self.zoom_levels = [1.0, 1.5, 2.0, 2.5]
|
||||
self.ui_scale = 1.0
|
||||
|
||||
self.time_data = deque()
|
||||
self.roll_data = deque()
|
||||
self.rate_data = deque()
|
||||
self.output_data = deque()
|
||||
|
||||
self.current_values = {
|
||||
"Time": 0, "Roll": 0.0, "Rate": 0.0, "Output": 0.0,
|
||||
"State": "DISCONNECTED", "ActiveKp": 0.0, "ActiveKd": 0.0,
|
||||
"Skew": 0.0,
|
||||
"Lat": 0.0, "Lon": 0.0, "Alt": 0.0, "GPS_State": 0
|
||||
}
|
||||
|
||||
self.mission_events = []
|
||||
self.last_state = "DISCONNECTED"
|
||||
|
||||
self.kp_var = tk.StringVar(value="0.5")
|
||||
self.kd_var = tk.StringVar(value="0.2")
|
||||
|
||||
self.rocket_ip = None
|
||||
self.running = True
|
||||
|
||||
self.build_gui()
|
||||
|
||||
self.listener_thread = threading.Thread(target=self.udp_listener, daemon=True)
|
||||
self.listener_thread.start()
|
||||
|
||||
self.watchdog_thread = threading.Thread(target=self.connection_watchdog, daemon=True)
|
||||
self.watchdog_thread.start()
|
||||
|
||||
self.gui_update_loop()
|
||||
|
||||
def s(self, val): return int(val * self.ui_scale)
|
||||
def f(self, size, weight="normal"): return ("Helvetica", self.s(size), weight)
|
||||
def fm(self, size): return ("Courier New", self.s(size), "bold")
|
||||
|
||||
def build_gui(self):
|
||||
style = ttk.Style()
|
||||
style.configure('TButton', font=('Helvetica', self.s(10)))
|
||||
style.configure('TLabelframe.Label', font=('Helvetica', self.s(10), 'bold'))
|
||||
|
||||
for widget in self.root.winfo_children(): widget.destroy()
|
||||
if hasattr(self, 'anim') and self.anim and self.anim.event_source:
|
||||
self.anim.event_source.stop()
|
||||
|
||||
self.setup_ui()
|
||||
self.setup_plot()
|
||||
|
||||
def update_scale_val(self, val): pass
|
||||
|
||||
def trigger_redraw(self, event):
|
||||
val = self.scale_slider.get()
|
||||
idx = int(round(val))
|
||||
self.scale_slider.set(idx)
|
||||
self.ui_scale = self.zoom_levels[idx]
|
||||
self.build_gui()
|
||||
|
||||
def setup_ui(self):
|
||||
control_frame = ttk.Frame(self.root, padding=self.s(10))
|
||||
control_frame.pack(side=tk.TOP, fill=tk.X)
|
||||
|
||||
ttk.Label(control_frame, text="Status:", font=self.f(10, "bold")).pack(side=tk.LEFT)
|
||||
self.status_label = ttk.Label(control_frame, text="Connecting to Launcher AP...", foreground="red", font=self.f(10))
|
||||
self.status_label.pack(side=tk.LEFT, padx=self.s(10))
|
||||
|
||||
scale_frame = ttk.Frame(control_frame)
|
||||
scale_frame.pack(side=tk.RIGHT, padx=self.s(10))
|
||||
ttk.Label(scale_frame, text="Zoom:", font=self.f(8)).pack(side=tk.LEFT)
|
||||
|
||||
current_idx = self.zoom_levels.index(self.ui_scale) if self.ui_scale in self.zoom_levels else 0
|
||||
self.scale_slider = ttk.Scale(scale_frame, from_=0, to=len(self.zoom_levels)-1, value=current_idx, command=self.update_scale_val)
|
||||
self.scale_slider.pack(side=tk.LEFT, padx=5)
|
||||
self.scale_slider.bind("<ButtonRelease-1>", self.trigger_redraw)
|
||||
|
||||
ttk.Button(control_frame, text="Save Graph", command=self.save_graph).pack(side=tk.RIGHT, padx=self.s(10))
|
||||
ttk.Button(control_frame, text="Reset Data", command=self.reset_dashboard).pack(side=tk.RIGHT, padx=self.s(10))
|
||||
|
||||
mission_frame = ttk.LabelFrame(self.root, text="Mission Control", padding=self.s(10))
|
||||
mission_frame.pack(side=tk.TOP, fill=tk.X, padx=self.s(10), pady=self.s(5))
|
||||
|
||||
state_container = ttk.Frame(mission_frame)
|
||||
state_container.pack(side=tk.LEFT, fill=tk.Y)
|
||||
ttk.Label(state_container, text="Rocket State:", font=self.f(11)).pack(side=tk.LEFT, padx=(0, self.s(10)))
|
||||
|
||||
dot_size = self.s(24)
|
||||
self.state_canvas = tk.Canvas(state_container, width=dot_size, height=dot_size, bg=self.root.cget("bg"), highlightthickness=0)
|
||||
self.state_canvas.pack(side=tk.LEFT)
|
||||
self.state_dot = self.state_canvas.create_oval(self.s(4), self.s(4), dot_size-self.s(4), dot_size-self.s(4), fill="gray", outline="gray")
|
||||
|
||||
self.lbl_state_text = ttk.Label(state_container, text="---", font=self.f(12, "bold"))
|
||||
self.lbl_state_text.pack(side=tk.LEFT, padx=self.s(10))
|
||||
|
||||
btn_frame = ttk.Frame(mission_frame)
|
||||
btn_frame.pack(side=tk.RIGHT)
|
||||
|
||||
ttk.Button(btn_frame, text="CALIBRATE GYRO", command=self.send_calibrate_command, width=int(20)).pack(side=tk.LEFT, padx=self.s(5))
|
||||
ttk.Button(btn_frame, text="DIGITAL LAUNCH", command=self.send_launch_command, width=int(20)).pack(side=tk.LEFT, padx=self.s(5))
|
||||
|
||||
tuning_frame = ttk.LabelFrame(self.root, text="PID Controller Tuning", padding=self.s(10))
|
||||
tuning_frame.pack(side=tk.TOP, fill=tk.X, padx=self.s(10), pady=self.s(5))
|
||||
|
||||
controls_frame = ttk.Frame(tuning_frame)
|
||||
controls_frame.pack(side=tk.LEFT, fill=tk.Y)
|
||||
ttk.Label(controls_frame, text="Kp (Proportional):", font=self.f(10)).grid(row=0, column=0, sticky="e", padx=(0, self.s(5)), pady=self.s(2))
|
||||
ttk.Spinbox(controls_frame, textvariable=self.kp_var, from_=0.0, to=10.0, increment=0.1, width=int(6*self.ui_scale), font=self.f(10, "bold")).grid(row=0, column=1, sticky="w", padx=(0, self.s(15)), pady=self.s(2))
|
||||
ttk.Label(controls_frame, text="Kd (Derivative):", font=self.f(10)).grid(row=1, column=0, sticky="e", padx=(0, self.s(5)), pady=self.s(2))
|
||||
ttk.Spinbox(controls_frame, textvariable=self.kd_var, from_=0.0, to=10.0, increment=0.05, width=int(6*self.ui_scale), font=self.f(10, "bold")).grid(row=1, column=1, sticky="w", padx=(0, self.s(15)), pady=self.s(2))
|
||||
ttk.Button(controls_frame, text="UPLOAD\nNEW PID", command=self.send_pid_command, width=int(12*self.ui_scale)).grid(row=0, column=2, rowspan=2, sticky="ns", padx=self.s(10), pady=self.s(2))
|
||||
|
||||
ttk.Separator(tuning_frame, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=self.s(20))
|
||||
|
||||
active_frame = ttk.Frame(tuning_frame)
|
||||
active_frame.pack(side=tk.LEFT, padx=self.s(10))
|
||||
ttk.Label(active_frame, text="ACTIVE HARDWARE SETTINGS:", font=self.f(8, "bold"), foreground="gray").pack(side=tk.TOP, anchor="w")
|
||||
display_container = tk.Frame(active_frame, bg="white", bd=1, relief=tk.SOLID, padx=self.s(10), pady=self.s(5))
|
||||
display_container.pack(side=tk.TOP, anchor="w", pady=(self.s(5), 0))
|
||||
self.lbl_active_pid = tk.Label(display_container, text="Kp: --.--- | Kd: --.---", font=self.fm(13), fg="#004488", bg="white")
|
||||
self.lbl_active_pid.pack(side=tk.LEFT)
|
||||
|
||||
# --- ENVIRONMENT & LOCATION FRAME ---
|
||||
env_frame = ttk.LabelFrame(self.root, text="Environment & Location", padding=self.s(10))
|
||||
env_frame.pack(side=tk.TOP, fill=tk.X, padx=self.s(10), pady=self.s(5))
|
||||
|
||||
# New GPS State Bubble
|
||||
gps_status_frame = ttk.Frame(env_frame)
|
||||
gps_status_frame.grid(row=0, column=0, padx=self.s(15), sticky="ew")
|
||||
ttk.Label(gps_status_frame, text="GPS Status", font=self.f(9)).pack(side=tk.TOP)
|
||||
bubble_frame = ttk.Frame(gps_status_frame)
|
||||
bubble_frame.pack(side=tk.TOP, pady=self.s(5))
|
||||
|
||||
gps_dot_size = self.s(18)
|
||||
self.gps_canvas = tk.Canvas(bubble_frame, width=gps_dot_size, height=gps_dot_size, bg=self.root.cget("bg"), highlightthickness=0)
|
||||
self.gps_canvas.pack(side=tk.LEFT)
|
||||
self.gps_dot = self.gps_canvas.create_oval(self.s(2), self.s(2), gps_dot_size-self.s(2), gps_dot_size-self.s(2), fill="red", outline="gray")
|
||||
|
||||
self.lbl_gps_text = ttk.Label(bubble_frame, text="NO NMEA", font=self.f(10, "bold"), foreground="red")
|
||||
self.lbl_gps_text.pack(side=tk.LEFT, padx=self.s(5))
|
||||
|
||||
self.lbl_alt = self.create_stat_label(env_frame, "Altitude (m ASL)", 1)
|
||||
self.lbl_lat = self.create_stat_label(env_frame, "Latitude", 2)
|
||||
self.lbl_lon = self.create_stat_label(env_frame, "Longitude", 3)
|
||||
|
||||
# --- LIVE TELEMETRY FRAME ---
|
||||
stats_frame = ttk.LabelFrame(self.root, text="Live Telemetry", padding=self.s(10))
|
||||
stats_frame.pack(side=tk.TOP, fill=tk.X, padx=self.s(10), pady=self.s(5))
|
||||
self.lbl_time = self.create_stat_label(stats_frame, "Time (ms)", 0)
|
||||
self.lbl_roll = self.create_stat_label(stats_frame, "Roll (°)", 1)
|
||||
self.lbl_rate = self.create_stat_label(stats_frame, "Rate (°/s)", 2)
|
||||
self.lbl_out = self.create_stat_label(stats_frame, "Servo Output", 3)
|
||||
self.lbl_skew = self.create_stat_label(stats_frame, "Skew (°)", 4)
|
||||
|
||||
def create_stat_label(self, parent, title, col):
|
||||
frame = ttk.Frame(parent)
|
||||
frame.grid(row=0, column=col, padx=self.s(15), sticky="ew")
|
||||
ttk.Label(frame, text=title, font=self.f(9)).pack()
|
||||
value_label = ttk.Label(frame, text="---", font=self.fm(14))
|
||||
value_label.pack()
|
||||
return value_label
|
||||
|
||||
def setup_plot(self):
|
||||
plt.rcParams.update({'font.size': 10 * self.ui_scale})
|
||||
self.fig, self.ax = plt.subplots(figsize=(8, 4), dpi=100)
|
||||
self.fig.patch.set_facecolor('#f0f0f0')
|
||||
self.line_roll, = self.ax.plot([], [], label='Roll Angle', color='tab:blue', linewidth=2)
|
||||
self.line_rate, = self.ax.plot([], [], label=f'Roll Rate (x{RATE_SCALE})', color='tab:orange', linewidth=1.5)
|
||||
self.ax.set_title("Rocket Stability Telemetry")
|
||||
self.ax.set_xlabel("Time (ms)")
|
||||
self.ax.set_ylabel("Value")
|
||||
self.ax.grid(True, linestyle=':', alpha=0.6)
|
||||
|
||||
from matplotlib.patches import Patch
|
||||
self.ax.legend(handles=[self.line_roll, self.line_rate, Patch(facecolor='green', alpha=0.3, label='Servo +'), Patch(facecolor='red', alpha=0.3, label='Servo -')], loc='upper left')
|
||||
|
||||
self.canvas = FigureCanvasTkAgg(self.fig, master=self.root)
|
||||
self.canvas.draw()
|
||||
self.canvas.get_tk_widget().pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=self.s(10), pady=self.s(10))
|
||||
self.anim = FuncAnimation(self.fig, self.update_plot, interval=100, blit=False, cache_frame_data=False)
|
||||
|
||||
def _send_udp_command(self, cmd):
|
||||
target_ip = self.rocket_ip if self.rocket_ip else LAUNCHER_GATEWAY
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.sendto(cmd.encode('utf-8'), (target_ip, UDP_PORT))
|
||||
except Exception as e:
|
||||
messagebox.showerror("Error", f"Failed to send '{cmd}':\n{e}")
|
||||
|
||||
def send_launch_command(self):
|
||||
if messagebox.askyesno("CONFIRM LAUNCH", "WARNING: This bypasses the physical launch button.\nEnsure Launcher is Armed and READY.\n\nProceed to IGNITE?"):
|
||||
self._send_udp_command("launch")
|
||||
|
||||
def send_calibrate_command(self):
|
||||
if messagebox.askyesno("Confirm", "Keep rocket STILL. Zeroing Gyro. Proceed?"):
|
||||
self._send_udp_command("calibrate")
|
||||
|
||||
def send_pid_command(self):
|
||||
try:
|
||||
self._send_udp_command(f"PID,{float(self.kp_var.get())},{float(self.kd_var.get())}")
|
||||
except ValueError:
|
||||
messagebox.showerror("Error", "Kp and Kd must be valid numbers.")
|
||||
|
||||
def connection_watchdog(self):
|
||||
while self.running:
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.sendto(b"HELLO", (LAUNCHER_GATEWAY, UDP_PORT))
|
||||
except: pass
|
||||
time.sleep(2)
|
||||
|
||||
def udp_listener(self):
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.bind((UDP_IP, UDP_PORT))
|
||||
while self.running:
|
||||
try:
|
||||
data, addr = sock.recvfrom(BUFFER_SIZE)
|
||||
message = data.decode('utf-8').strip()
|
||||
if self.rocket_ip != addr[0]:
|
||||
self.rocket_ip = addr[0]
|
||||
self.root.after(0, lambda: self.status_label.config(text=f"Connected: {self.rocket_ip}", foreground="green"))
|
||||
self.parse_data(message)
|
||||
except: pass
|
||||
|
||||
def parse_data(self, message):
|
||||
try:
|
||||
if message.startswith("STATUS:"):
|
||||
parts = message.split(":", 1)[1].strip().split(",")
|
||||
self.current_values["State"] = parts[0]
|
||||
if len(parts) >= 4:
|
||||
self.current_values["ActiveKp"] = float(parts[1])
|
||||
self.current_values["ActiveKd"] = float(parts[2])
|
||||
self.current_values["Skew"] = float(parts[3])
|
||||
|
||||
elif message.startswith("ENV,"):
|
||||
parts = message.split(',')
|
||||
if len(parts) >= 4:
|
||||
self.current_values["Lat"] = float(parts[1])
|
||||
self.current_values["Lon"] = float(parts[2])
|
||||
self.current_values["Alt"] = float(parts[3])
|
||||
if len(parts) >= 5: # Safely get the new GPS state variable
|
||||
self.current_values["GPS_State"] = int(parts[4])
|
||||
|
||||
elif message.startswith("T,") or message.startswith("[FUSION]"):
|
||||
if message.startswith("[FUSION]"): return
|
||||
parts = message.split(',')
|
||||
if len(parts) >= 5:
|
||||
t, r, rt, o = float(parts[1]), float(parts[2]), float(parts[3]), float(parts[4])
|
||||
self.time_data.append(t)
|
||||
self.roll_data.append(r)
|
||||
self.rate_data.append(rt)
|
||||
self.output_data.append(o)
|
||||
self.current_values.update({"Time": t, "Roll": r, "Rate": rt, "Output": o})
|
||||
|
||||
state = self.current_values.get("State", "DISCONNECTED")
|
||||
if state != self.last_state:
|
||||
if self.last_state != "DISCONNECTED" and t > 0:
|
||||
self.mission_events.append({"time": t, "state": state})
|
||||
self.last_state = state
|
||||
except: pass
|
||||
|
||||
def gui_update_loop(self):
|
||||
if self.running:
|
||||
self.update_stats()
|
||||
self.root.after(100, self.gui_update_loop)
|
||||
|
||||
def update_stats(self):
|
||||
if not hasattr(self, 'lbl_time') or not self.lbl_time.winfo_exists(): return
|
||||
vals = self.current_values
|
||||
self.lbl_time.config(text=f"{int(vals['Time'])}")
|
||||
self.lbl_roll.config(text=f"{vals['Roll']:.2f}")
|
||||
self.lbl_rate.config(text=f"{vals['Rate']:.2f}")
|
||||
self.lbl_out.config(text=f"{vals['Output']:.2f}")
|
||||
self.lbl_skew.config(text=f"{vals['Skew']:.2f}")
|
||||
|
||||
# New Environment UI Updates
|
||||
if hasattr(self, 'lbl_alt'):
|
||||
self.lbl_alt.config(text=f"{vals['Alt']:.1f}")
|
||||
self.lbl_lat.config(text=f"{vals['Lat']:.6f}")
|
||||
self.lbl_lon.config(text=f"{vals['Lon']:.6f}")
|
||||
|
||||
# Update GPS Status Bubble
|
||||
gps_state = vals.get("GPS_State", 0)
|
||||
if gps_state == 0:
|
||||
self.gps_canvas.itemconfig(self.gps_dot, fill="red", outline="red")
|
||||
self.lbl_gps_text.config(text="NO NMEA", foreground="red")
|
||||
elif gps_state == 1:
|
||||
self.gps_canvas.itemconfig(self.gps_dot, fill="orange", outline="orange")
|
||||
self.lbl_gps_text.config(text="SEARCHING", foreground="orange")
|
||||
elif gps_state == 2:
|
||||
self.gps_canvas.itemconfig(self.gps_dot, fill="green", outline="green")
|
||||
self.lbl_gps_text.config(text="FIX ACQUIRED", foreground="green")
|
||||
|
||||
state = vals["State"]
|
||||
self.lbl_state_text.config(text=state)
|
||||
self.lbl_active_pid.config(text=f"Kp: {vals['ActiveKp']:.3f} | Kd: {vals['ActiveKd']:.3f}")
|
||||
|
||||
color_map = {"IDLE": "gray", "ARMED": "#F4D03F", "IGNITING": "#FF8C00", "FLIGHT": "green", "DISCONNECTED": "gray"}
|
||||
dot_color = color_map.get(state, "gray")
|
||||
self.state_canvas.itemconfig(self.state_dot, fill=dot_color, outline=dot_color)
|
||||
|
||||
def update_plot(self, frame):
|
||||
if not self.time_data: return self.line_roll, self.line_rate
|
||||
|
||||
t, roll, rate = list(self.time_data)[-VIEW_POINTS:], list(self.roll_data)[-VIEW_POINTS:], list(self.rate_data)[-VIEW_POINTS:]
|
||||
out = np.array(list(self.output_data)[-VIEW_POINTS:])
|
||||
rate_plot = [r * RATE_SCALE for r in rate]
|
||||
|
||||
self.line_roll.set_data(t, roll)
|
||||
self.line_rate.set_data(t, rate_plot)
|
||||
for collection in self.ax.collections: collection.remove()
|
||||
self.ax.fill_between(t, out, 0, where=(out >= 0), interpolate=True, color='green', alpha=0.3)
|
||||
self.ax.fill_between(t, out, 0, where=(out < 0), interpolate=True, color='red', alpha=0.3)
|
||||
self.ax.set_xlim(min(t), max(t) + 1)
|
||||
|
||||
limit = 10.0
|
||||
all_y = roll + rate_plot + list(out)
|
||||
if all_y and max(abs(y) for y in all_y) > limit: limit = max(abs(y) for y in all_y) * 1.1
|
||||
self.ax.set_ylim(-limit, limit)
|
||||
return self.line_roll, self.line_rate
|
||||
|
||||
def reset_dashboard(self):
|
||||
self.time_data.clear(); self.roll_data.clear(); self.rate_data.clear(); self.output_data.clear()
|
||||
self.mission_events.clear()
|
||||
self.current_values = {"Time": 0, "Roll": 0.0, "Rate": 0.0, "Output": 0.0, "State": "DISCONNECTED", "ActiveKp": 0.0, "ActiveKd": 0.0, "Skew": 0.0, "Lat": 0.0, "Lon": 0.0, "Alt": 0.0, "GPS_State": 0}
|
||||
self.last_state = "DISCONNECTED"
|
||||
self.update_stats()
|
||||
|
||||
def save_graph(self):
|
||||
if hasattr(self, 'anim') and self.anim.event_source:
|
||||
self.anim.event_source.stop()
|
||||
|
||||
file_path = filedialog.asksaveasfilename(
|
||||
defaultextension=".png",
|
||||
filetypes=[("PNG Image", "*.png"), ("All Files", "*.*")],
|
||||
title="Save Telemetry Graph"
|
||||
)
|
||||
|
||||
if file_path:
|
||||
try:
|
||||
data_len = len(self.time_data)
|
||||
width = max(12, min(200, data_len / 50)) if data_len > 0 else 12
|
||||
save_fig, save_ax = plt.subplots(figsize=(width, 4), dpi=100)
|
||||
|
||||
t = list(self.time_data)
|
||||
roll = list(self.roll_data)
|
||||
rate = [r * RATE_SCALE for r in self.rate_data]
|
||||
out = np.array(self.output_data)
|
||||
|
||||
save_ax.plot(t, roll, label='Roll Angle', color='tab:blue', linewidth=2)
|
||||
save_ax.plot(t, rate, label=f'Roll Rate (x{RATE_SCALE})', color='tab:orange', linewidth=1.5)
|
||||
|
||||
save_ax.fill_between(t, out, 0, where=(out >= 0), interpolate=True, color='green', alpha=0.3)
|
||||
save_ax.fill_between(t, out, 0, where=(out < 0), interpolate=True, color='red', alpha=0.3)
|
||||
|
||||
y_max = max(max(roll) if roll else 10, max(rate) if rate else 10) * 0.9
|
||||
for event in self.mission_events:
|
||||
ev_time = event["time"]
|
||||
ev_name = event["state"]
|
||||
save_ax.axvline(x=ev_time, color='black', linestyle='--', alpha=0.6)
|
||||
save_ax.text(ev_time, y_max, f" {ev_name}", rotation=90, verticalalignment='top', fontsize=9, fontweight='bold', color='black')
|
||||
|
||||
save_ax.set_title(f"Rocket Flight Data - {len(t)} points")
|
||||
save_ax.legend()
|
||||
|
||||
if t: save_ax.set_xlim(min(t), max(t) + 1)
|
||||
|
||||
save_fig.savefig(file_path, dpi=100, bbox_inches='tight')
|
||||
plt.close(save_fig)
|
||||
messagebox.showinfo("Success", "Graph saved successfully.")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error saving graph: {e}")
|
||||
|
||||
if hasattr(self, 'anim') and self.anim.event_source:
|
||||
self.anim.event_source.start()
|
||||
|
||||
def on_close(self):
|
||||
self.running = False
|
||||
self.root.destroy()
|
||||
|
||||
if __name__ == "__main__":
|
||||
root = tk.Tk()
|
||||
app = TelemetryApp(root)
|
||||
root.protocol("WM_DELETE_WINDOW", app.on_close)
|
||||
root.mainloop()
|
||||
464
Firmware/launcher.txt
Normal file
464
Firmware/launcher.txt
Normal file
@@ -0,0 +1,464 @@
|
||||
/*
|
||||
* LAUNCHER ESP32 CODE (WiFi AP + Comm Relay + Fusion + GPS + Barometer)
|
||||
* V4 - Fully Non-Blocking & GPS Status Tracking
|
||||
*/
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <SPI.h>
|
||||
#include <Wire.h>
|
||||
#include <WiFi.h>
|
||||
#include <WiFiUdp.h>
|
||||
#include <ESP32Servo.h>
|
||||
#include <QMC5883LCompass.h>
|
||||
#include <TinyGPS++.h>
|
||||
#include <Adafruit_BMP085.h>
|
||||
|
||||
const char* ssid = "ROCKET_LAUNCHER";
|
||||
const char* password = "launch_secure";
|
||||
const int udpPort = 4444;
|
||||
WiFiUDP udp;
|
||||
IPAddress dashboardIP;
|
||||
bool dashboardConnected = false;
|
||||
|
||||
const int RX2_PIN = 16;
|
||||
const int TX2_PIN = 17;
|
||||
const int SWITCH_PIN = 5;
|
||||
const int BUTTON_PIN = 18;
|
||||
const int LED_PIN = 23;
|
||||
const int BUZZER_PIN = 2;
|
||||
const int LAUNCHER_SERVO_PIN = 19;
|
||||
const int I2C_SDA = 21;
|
||||
const int I2C_SCL = 22;
|
||||
const int GPS_RX_PIN = 4; // Using D4 for GPS RX
|
||||
|
||||
const int SERVO_ON = 170;
|
||||
const int SERVO_OFF = 55;
|
||||
|
||||
enum SystemState { SAFE, ARMING, READY, IGNITING };
|
||||
SystemState currentState = SAFE;
|
||||
|
||||
Servo launcherServo;
|
||||
QMC5883LCompass compass;
|
||||
TinyGPSPlus gps;
|
||||
HardwareSerial SerialGPS(1); // Use Hardware Serial 1 for GPS
|
||||
Adafruit_BMP085 bmp;
|
||||
|
||||
// Environment Filter Variables
|
||||
float filteredAlt = 0.0;
|
||||
const float ALT_ALPHA = 0.15; // Filter strength for Altitude (0.0 to 1.0)
|
||||
|
||||
unsigned long lastHeartbeatTime = 0;
|
||||
const unsigned long HEARTBEAT_TIMEOUT = 2000;
|
||||
|
||||
float mpu_ax = 0.0, mpu_ay = 0.0, mpu_az = 0.0;
|
||||
float cal_ay = 0.0, cal_az = 1.0;
|
||||
|
||||
const float MAG_OFFSET_X = -211.00;
|
||||
const float MAG_OFFSET_Y = 897.00;
|
||||
const float MAG_OFFSET_Z = -514.50;
|
||||
const float MAG_SCALE_X = 0.988;
|
||||
const float MAG_SCALE_Y = 0.971;
|
||||
const float MAG_SCALE_Z = 1.044;
|
||||
|
||||
bool udpLaunchTriggered = false;
|
||||
bool rocketReady = false;
|
||||
bool rocketIgnited = false;
|
||||
|
||||
void buzzerOn() { digitalWrite(BUZZER_PIN, LOW); }
|
||||
void buzzerOff() { digitalWrite(BUZZER_PIN, HIGH); }
|
||||
|
||||
void beep(int ms) { buzzerOn(); delay(ms); buzzerOff(); }
|
||||
void successTone() { beep(150); delay(150); beep(150); }
|
||||
void errorTone() { for(int i=0; i<5; i++) { beep(100); delay(100); } }
|
||||
|
||||
bool isSwitchArmed() { return digitalRead(SWITCH_PIN) == LOW; }
|
||||
bool isButtonPressed() { return digitalRead(BUTTON_PIN) == LOW; }
|
||||
|
||||
void abortSequence(String reason);
|
||||
void waitForSwitchReset();
|
||||
void updateAndPrintFusion();
|
||||
void processGPS();
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
|
||||
// Configure hardware serial buffer to prevent data overflow at 115200 baud
|
||||
Serial2.setRxBufferSize(2048);
|
||||
Serial2.begin(115200, SERIAL_8N1, RX2_PIN, TX2_PIN);
|
||||
Serial2.setTimeout(20);
|
||||
|
||||
// Setup GPS Serial on the RX pin (ignoring TX by passing -1)
|
||||
SerialGPS.begin(9600, SERIAL_8N1, GPS_RX_PIN, -1);
|
||||
|
||||
Serial.println("Starting WiFi Access Point...");
|
||||
WiFi.softAP(ssid, password);
|
||||
IPAddress IP = WiFi.softAPIP();
|
||||
Serial.print("AP IP address: ");
|
||||
Serial.println(IP);
|
||||
udp.begin(udpPort);
|
||||
|
||||
Wire.begin(I2C_SDA, I2C_SCL);
|
||||
compass.init();
|
||||
compass.setSmoothing(10, true);
|
||||
Wire.setClock(400000);
|
||||
|
||||
pinMode(SWITCH_PIN, INPUT_PULLUP);
|
||||
pinMode(BUTTON_PIN, INPUT_PULLUP);
|
||||
pinMode(LED_PIN, OUTPUT);
|
||||
pinMode(BUZZER_PIN, OUTPUT);
|
||||
buzzerOff();
|
||||
digitalWrite(LED_PIN, LOW);
|
||||
|
||||
launcherServo.setPeriodHertz(50);
|
||||
launcherServo.attach(LAUNCHER_SERVO_PIN);
|
||||
launcherServo.write(SERVO_OFF);
|
||||
|
||||
if (bmp.begin()) {
|
||||
filteredAlt = bmp.readAltitude(); // Seed the filter
|
||||
Serial.println("BMP180 initialized.");
|
||||
} else {
|
||||
Serial.println("Warning: BMP180 not found.");
|
||||
}
|
||||
|
||||
// --- GPS STARTUP CHECK ---
|
||||
Serial.println("Checking GPS NMEA Data...");
|
||||
unsigned long gpsStart = millis();
|
||||
bool gpsWorks = false;
|
||||
|
||||
while (millis() - gpsStart < 5000) {
|
||||
while (SerialGPS.available() > 0) {
|
||||
char c = SerialGPS.read();
|
||||
if (c == '$') { // Standard start of an NMEA sentence
|
||||
gpsWorks = true;
|
||||
}
|
||||
gps.encode(c);
|
||||
}
|
||||
if (gpsWorks) break;
|
||||
delay(10);
|
||||
}
|
||||
|
||||
if (!gpsWorks) {
|
||||
Serial.println("CRITICAL: NO GPS DATA! ABORTING LAUNCHER STARTUP.");
|
||||
while (true) { // Halt and play error loop
|
||||
digitalWrite(LED_PIN, HIGH);
|
||||
errorTone();
|
||||
digitalWrite(LED_PIN, LOW);
|
||||
delay(1000);
|
||||
}
|
||||
}
|
||||
|
||||
Serial.println("GPS Test Passed.");
|
||||
delay(500);
|
||||
successTone();
|
||||
}
|
||||
|
||||
void sendToDashboard(String msg) {
|
||||
if (dashboardConnected) {
|
||||
udp.beginPacket(dashboardIP, udpPort);
|
||||
udp.println(msg);
|
||||
udp.endPacket();
|
||||
}
|
||||
}
|
||||
|
||||
void processGPS() {
|
||||
while (SerialGPS.available() > 0) {
|
||||
gps.encode(SerialGPS.read());
|
||||
}
|
||||
}
|
||||
|
||||
void processSerial2() {
|
||||
while (Serial2.available()) {
|
||||
String msg = Serial2.readStringUntil('\n');
|
||||
msg.trim();
|
||||
|
||||
if (msg.indexOf("READY") != -1) {
|
||||
rocketReady = true;
|
||||
}
|
||||
else if (msg.indexOf("IGNITED") != -1) {
|
||||
rocketIgnited = true;
|
||||
}
|
||||
else if (msg.startsWith("DATA,") || msg.startsWith("ALIVE")) {
|
||||
lastHeartbeatTime = millis();
|
||||
|
||||
if (msg.startsWith("DATA,")) {
|
||||
int c[10]; // 10 commas for the new skew array
|
||||
c[0] = msg.indexOf(',');
|
||||
for(int i=1; i<10; i++) c[i] = msg.indexOf(',', c[i-1] + 1);
|
||||
|
||||
if (c[9] > 0) { // Check that all fields arrived
|
||||
mpu_ax = msg.substring(c[0]+1, c[1]).toFloat();
|
||||
mpu_ay = msg.substring(c[1]+1, c[2]).toFloat();
|
||||
mpu_az = msg.substring(c[2]+1, c[3]).toFloat();
|
||||
|
||||
String roll = msg.substring(c[3]+1, c[4]);
|
||||
String rate = msg.substring(c[4]+1, c[5]);
|
||||
String out = msg.substring(c[5]+1, c[6]);
|
||||
String state = msg.substring(c[6]+1, c[7]);
|
||||
String kp = msg.substring(c[7]+1, c[8]);
|
||||
String kd = msg.substring(c[8]+1, c[9]);
|
||||
String skew = msg.substring(c[9]+1);
|
||||
|
||||
sendToDashboard("T," + String(millis()) + "," + roll + "," + rate + "," + out);
|
||||
sendToDashboard("STATUS:" + state + "," + kp + "," + kd + "," + skew);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void processUDP() {
|
||||
int packetSize = udp.parsePacket();
|
||||
if (packetSize) {
|
||||
char buffer[packetSize + 1];
|
||||
udp.read(buffer, packetSize);
|
||||
buffer[packetSize] = '\0';
|
||||
|
||||
String msg = String(buffer);
|
||||
msg.trim();
|
||||
|
||||
if (!dashboardConnected) {
|
||||
dashboardIP = udp.remoteIP();
|
||||
dashboardConnected = true;
|
||||
Serial.println("Dashboard Connected via WiFi!");
|
||||
}
|
||||
|
||||
if (msg == "HELLO") {
|
||||
dashboardIP = udp.remoteIP();
|
||||
} else if (msg == "launch") {
|
||||
udpLaunchTriggered = true;
|
||||
} else if (msg == "calibrate") {
|
||||
Serial2.println("CALIBRATE");
|
||||
} else if (msg.startsWith("PID,")) {
|
||||
Serial2.println(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loop() {
|
||||
processUDP();
|
||||
processGPS();
|
||||
|
||||
if (!isSwitchArmed() && currentState != SAFE) {
|
||||
abortSequence("Arming switch flipped OFF.");
|
||||
return;
|
||||
}
|
||||
|
||||
switch (currentState) {
|
||||
case SAFE:
|
||||
launcherServo.write(SERVO_OFF);
|
||||
digitalWrite(LED_PIN, LOW);
|
||||
buzzerOff();
|
||||
processSerial2();
|
||||
|
||||
if (isSwitchArmed()) {
|
||||
currentState = ARMING;
|
||||
Serial.println("ARMING Rocket...");
|
||||
}
|
||||
break;
|
||||
|
||||
case ARMING: {
|
||||
launcherServo.write(SERVO_ON);
|
||||
unsigned long armStart = millis();
|
||||
rocketReady = false;
|
||||
|
||||
while (millis() - armStart < 8000) {
|
||||
processUDP();
|
||||
processSerial2();
|
||||
processGPS();
|
||||
|
||||
if (!isSwitchArmed()) return;
|
||||
if (rocketReady) break;
|
||||
delay(10);
|
||||
}
|
||||
|
||||
if (!rocketReady) { abortSequence("Rocket Timeout."); return; }
|
||||
|
||||
for(int i = 0; i < 3; i++) {
|
||||
digitalWrite(LED_PIN, HIGH); beep(80); digitalWrite(LED_PIN, LOW); delay(80);
|
||||
}
|
||||
delay(1000);
|
||||
|
||||
float sumAy = 0, sumAz = 0;
|
||||
int samples = 0;
|
||||
unsigned long startWait = millis();
|
||||
while(millis() - startWait < 150) {
|
||||
processSerial2();
|
||||
processGPS();
|
||||
sumAy += mpu_ay;
|
||||
sumAz += mpu_az;
|
||||
samples++;
|
||||
delay(5);
|
||||
}
|
||||
|
||||
if (samples > 0) {
|
||||
float L = sqrt((sumAy/samples)*(sumAy/samples) + (sumAz/samples)*(sumAz/samples));
|
||||
if (L > 0.1) { cal_ay = (sumAy/samples) / L; cal_az = (sumAz/samples) / L; }
|
||||
}
|
||||
|
||||
currentState = READY;
|
||||
lastHeartbeatTime = millis();
|
||||
digitalWrite(LED_PIN, HIGH);
|
||||
|
||||
Serial2.println("ARM");
|
||||
successTone();
|
||||
break;
|
||||
}
|
||||
|
||||
case READY: {
|
||||
processSerial2();
|
||||
|
||||
if (millis() - lastHeartbeatTime > HEARTBEAT_TIMEOUT) {
|
||||
abortSequence("Lost heartbeat from Rocket!");
|
||||
return;
|
||||
}
|
||||
|
||||
bool triggered = false;
|
||||
if (udpLaunchTriggered) {
|
||||
triggered = true;
|
||||
udpLaunchTriggered = false;
|
||||
} else if (isButtonPressed()) {
|
||||
unsigned long triggerStart = millis();
|
||||
buzzerOn();
|
||||
while (isButtonPressed() && millis() - triggerStart < 1000) {
|
||||
processUDP();
|
||||
processSerial2();
|
||||
processGPS();
|
||||
if (!isSwitchArmed()) { buzzerOff(); return; }
|
||||
delay(10);
|
||||
}
|
||||
buzzerOff();
|
||||
if (isButtonPressed()) triggered = true;
|
||||
}
|
||||
|
||||
if (triggered) {
|
||||
currentState = IGNITING;
|
||||
Serial2.println("CALIBRATE");
|
||||
delay(20);
|
||||
Serial2.println("IGNITE");
|
||||
Serial.println("IGNITION COMMAND SENT");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case IGNITING: {
|
||||
unsigned long igniteStart = millis();
|
||||
rocketIgnited = false;
|
||||
while (millis() - igniteStart < 5000) {
|
||||
processUDP();
|
||||
processSerial2();
|
||||
processGPS();
|
||||
if (rocketIgnited) break;
|
||||
delay(10);
|
||||
}
|
||||
|
||||
if (rocketIgnited) {
|
||||
beep(200);
|
||||
digitalWrite(LED_PIN, LOW);
|
||||
waitForSwitchReset();
|
||||
} else {
|
||||
abortSequence("No IGNITED ACK received.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
updateAndPrintFusion();
|
||||
|
||||
// Transmit Environment and GPS Status regularly
|
||||
static unsigned long lastEnvSend = 0;
|
||||
static uint32_t lastCharsProcessed = 0;
|
||||
|
||||
if (millis() - lastEnvSend > 1000) {
|
||||
lastEnvSend = millis();
|
||||
|
||||
// Simple EMA Filter for BMP Altitude
|
||||
float newAlt = bmp.readAltitude();
|
||||
filteredAlt = (ALT_ALPHA * newAlt) + ((1.0 - ALT_ALPHA) * filteredAlt);
|
||||
|
||||
float lat = 0.0, lon = 0.0;
|
||||
int gpsState = 0; // 0=Red(No NMEA), 1=Orange(Searching), 2=Green(Fix)
|
||||
|
||||
uint32_t currentChars = gps.charsProcessed();
|
||||
|
||||
if (currentChars == lastCharsProcessed) {
|
||||
gpsState = 0; // Module stopped sending or disconnected
|
||||
} else if (!gps.location.isValid() || gps.location.lat() == 0.0) {
|
||||
gpsState = 1; // Seeing NMEA sentences, but no satellite fix yet
|
||||
} else {
|
||||
gpsState = 2; // Full fix!
|
||||
lat = gps.location.lat();
|
||||
lon = gps.location.lng();
|
||||
}
|
||||
lastCharsProcessed = currentChars;
|
||||
|
||||
// Send: ENV,lat,lon,alt,gpsState
|
||||
char envMsg[128];
|
||||
snprintf(envMsg, sizeof(envMsg), "ENV,%.6f,%.6f,%.1f,%d", lat, lon, filteredAlt, gpsState);
|
||||
sendToDashboard(String(envMsg));
|
||||
}
|
||||
}
|
||||
|
||||
void updateAndPrintFusion() {
|
||||
static unsigned long lastFusionTime = 0;
|
||||
if (millis() - lastFusionTime < 500) return;
|
||||
lastFusionTime = millis();
|
||||
|
||||
compass.read();
|
||||
int qmc_x_raw = compass.getX();
|
||||
int qmc_y_raw = compass.getY();
|
||||
int qmc_z_raw = compass.getZ();
|
||||
|
||||
if (qmc_x_raw == 0 && qmc_y_raw == 0 && qmc_z_raw == 0) {
|
||||
Wire.end(); delay(50); Wire.begin(I2C_SDA, I2C_SCL);
|
||||
compass.init(); compass.setSmoothing(10, true);
|
||||
Wire.setClock(400000);
|
||||
return;
|
||||
}
|
||||
|
||||
float cal_x = (qmc_x_raw - MAG_OFFSET_X) * MAG_SCALE_X;
|
||||
float cal_y = (qmc_y_raw - MAG_OFFSET_Y) * MAG_SCALE_Y;
|
||||
float cal_z = (qmc_z_raw - MAG_OFFSET_Z) * MAG_SCALE_Z;
|
||||
|
||||
float g_fwd = mpu_ax;
|
||||
float g_right = mpu_az * cal_ay - mpu_ay * cal_az;
|
||||
float g_down = mpu_ay * cal_ay + mpu_az * cal_az;
|
||||
|
||||
float pitch = atan2(-g_fwd, sqrt(g_right * g_right + g_down * g_down));
|
||||
float roll = atan2(g_right, g_down);
|
||||
|
||||
float x_m = cal_y * cos(pitch) + cal_z * sin(roll) * sin(pitch) + cal_x * cos(roll) * sin(pitch);
|
||||
float y_m = cal_z * cos(roll) - cal_x * sin(roll);
|
||||
|
||||
float heading = atan2(y_m, x_m) * 180.0 / PI;
|
||||
if (heading < 0) heading += 360;
|
||||
|
||||
char fusionMsg[128];
|
||||
snprintf(fusionMsg, sizeof(fusionMsg), "[FUSION] Hdg: %03.1f | Pitch: %+02.1f", heading, pitch * 180.0/PI);
|
||||
sendToDashboard(String(fusionMsg));
|
||||
}
|
||||
|
||||
void abortSequence(String reason) {
|
||||
launcherServo.write(SERVO_OFF);
|
||||
digitalWrite(LED_PIN, LOW);
|
||||
errorTone();
|
||||
waitForSwitchReset();
|
||||
}
|
||||
|
||||
void waitForSwitchReset() {
|
||||
currentState = SAFE;
|
||||
unsigned long lastBlink = 0;
|
||||
bool ledState = false;
|
||||
|
||||
while (isSwitchArmed()) {
|
||||
processUDP();
|
||||
processSerial2();
|
||||
processGPS();
|
||||
|
||||
if (millis() - lastBlink > 100) {
|
||||
lastBlink = millis();
|
||||
ledState = !ledState;
|
||||
digitalWrite(LED_PIN, ledState ? HIGH : LOW);
|
||||
}
|
||||
}
|
||||
digitalWrite(LED_PIN, LOW);
|
||||
}
|
||||
411
Firmware/rocket.txt
Normal file
411
Firmware/rocket.txt
Normal file
@@ -0,0 +1,411 @@
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, filedialog, messagebox
|
||||
import socket
|
||||
import threading
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.animation import FuncAnimation
|
||||
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
|
||||
from collections import deque
|
||||
import time
|
||||
import numpy as np
|
||||
|
||||
# --- Configuration ---
|
||||
UDP_IP = "0.0.0.0"
|
||||
UDP_PORT = 4444
|
||||
BUFFER_SIZE = 1024
|
||||
VIEW_POINTS = 100
|
||||
RATE_SCALE = 0.25
|
||||
|
||||
LAUNCHER_GATEWAY = "192.168.4.1"
|
||||
|
||||
class TelemetryApp:
|
||||
def __init__(self, root):
|
||||
self.root = root
|
||||
self.root.title("Rocket Telemetry Dashboard - Ground Control")
|
||||
self.root.geometry("1100x850")
|
||||
|
||||
self.zoom_levels = [1.0, 1.5, 2.0, 2.5]
|
||||
self.ui_scale = 1.0
|
||||
|
||||
self.time_data = deque()
|
||||
self.roll_data = deque()
|
||||
self.rate_data = deque()
|
||||
self.output_data = deque()
|
||||
|
||||
self.current_values = {
|
||||
"Time": 0, "Roll": 0.0, "Rate": 0.0, "Output": 0.0,
|
||||
"State": "DISCONNECTED", "ActiveKp": 0.0, "ActiveKd": 0.0,
|
||||
"Skew": 0.0,
|
||||
"Lat": 0.0, "Lon": 0.0, "Alt": 0.0, "GPS_State": 0
|
||||
}
|
||||
|
||||
self.mission_events = []
|
||||
self.last_state = "DISCONNECTED"
|
||||
|
||||
self.kp_var = tk.StringVar(value="0.5")
|
||||
self.kd_var = tk.StringVar(value="0.2")
|
||||
|
||||
self.rocket_ip = None
|
||||
self.running = True
|
||||
|
||||
self.build_gui()
|
||||
|
||||
self.listener_thread = threading.Thread(target=self.udp_listener, daemon=True)
|
||||
self.listener_thread.start()
|
||||
|
||||
self.watchdog_thread = threading.Thread(target=self.connection_watchdog, daemon=True)
|
||||
self.watchdog_thread.start()
|
||||
|
||||
self.gui_update_loop()
|
||||
|
||||
def s(self, val): return int(val * self.ui_scale)
|
||||
def f(self, size, weight="normal"): return ("Helvetica", self.s(size), weight)
|
||||
def fm(self, size): return ("Courier New", self.s(size), "bold")
|
||||
|
||||
def build_gui(self):
|
||||
style = ttk.Style()
|
||||
style.configure('TButton', font=('Helvetica', self.s(10)))
|
||||
style.configure('TLabelframe.Label', font=('Helvetica', self.s(10), 'bold'))
|
||||
|
||||
for widget in self.root.winfo_children(): widget.destroy()
|
||||
if hasattr(self, 'anim') and self.anim and self.anim.event_source:
|
||||
self.anim.event_source.stop()
|
||||
|
||||
self.setup_ui()
|
||||
self.setup_plot()
|
||||
|
||||
def update_scale_val(self, val): pass
|
||||
|
||||
def trigger_redraw(self, event):
|
||||
val = self.scale_slider.get()
|
||||
idx = int(round(val))
|
||||
self.scale_slider.set(idx)
|
||||
self.ui_scale = self.zoom_levels[idx]
|
||||
self.build_gui()
|
||||
|
||||
def setup_ui(self):
|
||||
control_frame = ttk.Frame(self.root, padding=self.s(10))
|
||||
control_frame.pack(side=tk.TOP, fill=tk.X)
|
||||
|
||||
ttk.Label(control_frame, text="Status:", font=self.f(10, "bold")).pack(side=tk.LEFT)
|
||||
self.status_label = ttk.Label(control_frame, text="Connecting to Launcher AP...", foreground="red", font=self.f(10))
|
||||
self.status_label.pack(side=tk.LEFT, padx=self.s(10))
|
||||
|
||||
scale_frame = ttk.Frame(control_frame)
|
||||
scale_frame.pack(side=tk.RIGHT, padx=self.s(10))
|
||||
ttk.Label(scale_frame, text="Zoom:", font=self.f(8)).pack(side=tk.LEFT)
|
||||
|
||||
current_idx = self.zoom_levels.index(self.ui_scale) if self.ui_scale in self.zoom_levels else 0
|
||||
self.scale_slider = ttk.Scale(scale_frame, from_=0, to=len(self.zoom_levels)-1, value=current_idx, command=self.update_scale_val)
|
||||
self.scale_slider.pack(side=tk.LEFT, padx=5)
|
||||
self.scale_slider.bind("<ButtonRelease-1>", self.trigger_redraw)
|
||||
|
||||
ttk.Button(control_frame, text="Save Graph", command=self.save_graph).pack(side=tk.RIGHT, padx=self.s(10))
|
||||
ttk.Button(control_frame, text="Reset Data", command=self.reset_dashboard).pack(side=tk.RIGHT, padx=self.s(10))
|
||||
|
||||
mission_frame = ttk.LabelFrame(self.root, text="Mission Control", padding=self.s(10))
|
||||
mission_frame.pack(side=tk.TOP, fill=tk.X, padx=self.s(10), pady=self.s(5))
|
||||
|
||||
state_container = ttk.Frame(mission_frame)
|
||||
state_container.pack(side=tk.LEFT, fill=tk.Y)
|
||||
ttk.Label(state_container, text="Rocket State:", font=self.f(11)).pack(side=tk.LEFT, padx=(0, self.s(10)))
|
||||
|
||||
dot_size = self.s(24)
|
||||
self.state_canvas = tk.Canvas(state_container, width=dot_size, height=dot_size, bg=self.root.cget("bg"), highlightthickness=0)
|
||||
self.state_canvas.pack(side=tk.LEFT)
|
||||
self.state_dot = self.state_canvas.create_oval(self.s(4), self.s(4), dot_size-self.s(4), dot_size-self.s(4), fill="gray", outline="gray")
|
||||
|
||||
self.lbl_state_text = ttk.Label(state_container, text="---", font=self.f(12, "bold"))
|
||||
self.lbl_state_text.pack(side=tk.LEFT, padx=self.s(10))
|
||||
|
||||
btn_frame = ttk.Frame(mission_frame)
|
||||
btn_frame.pack(side=tk.RIGHT)
|
||||
|
||||
ttk.Button(btn_frame, text="CALIBRATE GYRO", command=self.send_calibrate_command, width=int(20)).pack(side=tk.LEFT, padx=self.s(5))
|
||||
ttk.Button(btn_frame, text="DIGITAL LAUNCH", command=self.send_launch_command, width=int(20)).pack(side=tk.LEFT, padx=self.s(5))
|
||||
|
||||
tuning_frame = ttk.LabelFrame(self.root, text="PID Controller Tuning", padding=self.s(10))
|
||||
tuning_frame.pack(side=tk.TOP, fill=tk.X, padx=self.s(10), pady=self.s(5))
|
||||
|
||||
controls_frame = ttk.Frame(tuning_frame)
|
||||
controls_frame.pack(side=tk.LEFT, fill=tk.Y)
|
||||
ttk.Label(controls_frame, text="Kp (Proportional):", font=self.f(10)).grid(row=0, column=0, sticky="e", padx=(0, self.s(5)), pady=self.s(2))
|
||||
ttk.Spinbox(controls_frame, textvariable=self.kp_var, from_=0.0, to=10.0, increment=0.1, width=int(6*self.ui_scale), font=self.f(10, "bold")).grid(row=0, column=1, sticky="w", padx=(0, self.s(15)), pady=self.s(2))
|
||||
ttk.Label(controls_frame, text="Kd (Derivative):", font=self.f(10)).grid(row=1, column=0, sticky="e", padx=(0, self.s(5)), pady=self.s(2))
|
||||
ttk.Spinbox(controls_frame, textvariable=self.kd_var, from_=0.0, to=10.0, increment=0.05, width=int(6*self.ui_scale), font=self.f(10, "bold")).grid(row=1, column=1, sticky="w", padx=(0, self.s(15)), pady=self.s(2))
|
||||
ttk.Button(controls_frame, text="UPLOAD\nNEW PID", command=self.send_pid_command, width=int(12*self.ui_scale)).grid(row=0, column=2, rowspan=2, sticky="ns", padx=self.s(10), pady=self.s(2))
|
||||
|
||||
ttk.Separator(tuning_frame, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=self.s(20))
|
||||
|
||||
active_frame = ttk.Frame(tuning_frame)
|
||||
active_frame.pack(side=tk.LEFT, padx=self.s(10))
|
||||
ttk.Label(active_frame, text="ACTIVE HARDWARE SETTINGS:", font=self.f(8, "bold"), foreground="gray").pack(side=tk.TOP, anchor="w")
|
||||
display_container = tk.Frame(active_frame, bg="white", bd=1, relief=tk.SOLID, padx=self.s(10), pady=self.s(5))
|
||||
display_container.pack(side=tk.TOP, anchor="w", pady=(self.s(5), 0))
|
||||
self.lbl_active_pid = tk.Label(display_container, text="Kp: --.--- | Kd: --.---", font=self.fm(13), fg="#004488", bg="white")
|
||||
self.lbl_active_pid.pack(side=tk.LEFT)
|
||||
|
||||
# --- ENVIRONMENT & LOCATION FRAME ---
|
||||
env_frame = ttk.LabelFrame(self.root, text="Environment & Location", padding=self.s(10))
|
||||
env_frame.pack(side=tk.TOP, fill=tk.X, padx=self.s(10), pady=self.s(5))
|
||||
|
||||
# New GPS State Bubble
|
||||
gps_status_frame = ttk.Frame(env_frame)
|
||||
gps_status_frame.grid(row=0, column=0, padx=self.s(15), sticky="ew")
|
||||
ttk.Label(gps_status_frame, text="GPS Status", font=self.f(9)).pack(side=tk.TOP)
|
||||
bubble_frame = ttk.Frame(gps_status_frame)
|
||||
bubble_frame.pack(side=tk.TOP, pady=self.s(5))
|
||||
|
||||
gps_dot_size = self.s(18)
|
||||
self.gps_canvas = tk.Canvas(bubble_frame, width=gps_dot_size, height=gps_dot_size, bg=self.root.cget("bg"), highlightthickness=0)
|
||||
self.gps_canvas.pack(side=tk.LEFT)
|
||||
self.gps_dot = self.gps_canvas.create_oval(self.s(2), self.s(2), gps_dot_size-self.s(2), gps_dot_size-self.s(2), fill="red", outline="gray")
|
||||
|
||||
self.lbl_gps_text = ttk.Label(bubble_frame, text="NO NMEA", font=self.f(10, "bold"), foreground="red")
|
||||
self.lbl_gps_text.pack(side=tk.LEFT, padx=self.s(5))
|
||||
|
||||
self.lbl_alt = self.create_stat_label(env_frame, "Altitude (m ASL)", 1)
|
||||
self.lbl_lat = self.create_stat_label(env_frame, "Latitude", 2)
|
||||
self.lbl_lon = self.create_stat_label(env_frame, "Longitude", 3)
|
||||
|
||||
# --- LIVE TELEMETRY FRAME ---
|
||||
stats_frame = ttk.LabelFrame(self.root, text="Live Telemetry", padding=self.s(10))
|
||||
stats_frame.pack(side=tk.TOP, fill=tk.X, padx=self.s(10), pady=self.s(5))
|
||||
self.lbl_time = self.create_stat_label(stats_frame, "Time (ms)", 0)
|
||||
self.lbl_roll = self.create_stat_label(stats_frame, "Roll (°)", 1)
|
||||
self.lbl_rate = self.create_stat_label(stats_frame, "Rate (°/s)", 2)
|
||||
self.lbl_out = self.create_stat_label(stats_frame, "Servo Output", 3)
|
||||
self.lbl_skew = self.create_stat_label(stats_frame, "Skew (°)", 4)
|
||||
|
||||
def create_stat_label(self, parent, title, col):
|
||||
frame = ttk.Frame(parent)
|
||||
frame.grid(row=0, column=col, padx=self.s(15), sticky="ew")
|
||||
ttk.Label(frame, text=title, font=self.f(9)).pack()
|
||||
value_label = ttk.Label(frame, text="---", font=self.fm(14))
|
||||
value_label.pack()
|
||||
return value_label
|
||||
|
||||
def setup_plot(self):
|
||||
plt.rcParams.update({'font.size': 10 * self.ui_scale})
|
||||
self.fig, self.ax = plt.subplots(figsize=(8, 4), dpi=100)
|
||||
self.fig.patch.set_facecolor('#f0f0f0')
|
||||
self.line_roll, = self.ax.plot([], [], label='Roll Angle', color='tab:blue', linewidth=2)
|
||||
self.line_rate, = self.ax.plot([], [], label=f'Roll Rate (x{RATE_SCALE})', color='tab:orange', linewidth=1.5)
|
||||
self.ax.set_title("Rocket Stability Telemetry")
|
||||
self.ax.set_xlabel("Time (ms)")
|
||||
self.ax.set_ylabel("Value")
|
||||
self.ax.grid(True, linestyle=':', alpha=0.6)
|
||||
|
||||
from matplotlib.patches import Patch
|
||||
self.ax.legend(handles=[self.line_roll, self.line_rate, Patch(facecolor='green', alpha=0.3, label='Servo +'), Patch(facecolor='red', alpha=0.3, label='Servo -')], loc='upper left')
|
||||
|
||||
self.canvas = FigureCanvasTkAgg(self.fig, master=self.root)
|
||||
self.canvas.draw()
|
||||
self.canvas.get_tk_widget().pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=self.s(10), pady=self.s(10))
|
||||
self.anim = FuncAnimation(self.fig, self.update_plot, interval=100, blit=False, cache_frame_data=False)
|
||||
|
||||
def _send_udp_command(self, cmd):
|
||||
target_ip = self.rocket_ip if self.rocket_ip else LAUNCHER_GATEWAY
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.sendto(cmd.encode('utf-8'), (target_ip, UDP_PORT))
|
||||
except Exception as e:
|
||||
messagebox.showerror("Error", f"Failed to send '{cmd}':\n{e}")
|
||||
|
||||
def send_launch_command(self):
|
||||
if messagebox.askyesno("CONFIRM LAUNCH", "WARNING: This bypasses the physical launch button.\nEnsure Launcher is Armed and READY.\n\nProceed to IGNITE?"):
|
||||
self._send_udp_command("launch")
|
||||
|
||||
def send_calibrate_command(self):
|
||||
if messagebox.askyesno("Confirm", "Keep rocket STILL. Zeroing Gyro. Proceed?"):
|
||||
self._send_udp_command("calibrate")
|
||||
|
||||
def send_pid_command(self):
|
||||
try:
|
||||
self._send_udp_command(f"PID,{float(self.kp_var.get())},{float(self.kd_var.get())}")
|
||||
except ValueError:
|
||||
messagebox.showerror("Error", "Kp and Kd must be valid numbers.")
|
||||
|
||||
def connection_watchdog(self):
|
||||
while self.running:
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.sendto(b"HELLO", (LAUNCHER_GATEWAY, UDP_PORT))
|
||||
except: pass
|
||||
time.sleep(2)
|
||||
|
||||
def udp_listener(self):
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.bind((UDP_IP, UDP_PORT))
|
||||
while self.running:
|
||||
try:
|
||||
data, addr = sock.recvfrom(BUFFER_SIZE)
|
||||
message = data.decode('utf-8').strip()
|
||||
if self.rocket_ip != addr[0]:
|
||||
self.rocket_ip = addr[0]
|
||||
self.root.after(0, lambda: self.status_label.config(text=f"Connected: {self.rocket_ip}", foreground="green"))
|
||||
self.parse_data(message)
|
||||
except: pass
|
||||
|
||||
def parse_data(self, message):
|
||||
try:
|
||||
if message.startswith("STATUS:"):
|
||||
parts = message.split(":", 1)[1].strip().split(",")
|
||||
self.current_values["State"] = parts[0]
|
||||
if len(parts) >= 4:
|
||||
self.current_values["ActiveKp"] = float(parts[1])
|
||||
self.current_values["ActiveKd"] = float(parts[2])
|
||||
self.current_values["Skew"] = float(parts[3])
|
||||
|
||||
elif message.startswith("ENV,"):
|
||||
parts = message.split(',')
|
||||
if len(parts) >= 4:
|
||||
self.current_values["Lat"] = float(parts[1])
|
||||
self.current_values["Lon"] = float(parts[2])
|
||||
self.current_values["Alt"] = float(parts[3])
|
||||
if len(parts) >= 5: # Safely get the new GPS state variable
|
||||
self.current_values["GPS_State"] = int(parts[4])
|
||||
|
||||
elif message.startswith("T,") or message.startswith("[FUSION]"):
|
||||
if message.startswith("[FUSION]"): return
|
||||
parts = message.split(',')
|
||||
if len(parts) >= 5:
|
||||
t, r, rt, o = float(parts[1]), float(parts[2]), float(parts[3]), float(parts[4])
|
||||
self.time_data.append(t)
|
||||
self.roll_data.append(r)
|
||||
self.rate_data.append(rt)
|
||||
self.output_data.append(o)
|
||||
self.current_values.update({"Time": t, "Roll": r, "Rate": rt, "Output": o})
|
||||
|
||||
state = self.current_values.get("State", "DISCONNECTED")
|
||||
if state != self.last_state:
|
||||
if self.last_state != "DISCONNECTED" and t > 0:
|
||||
self.mission_events.append({"time": t, "state": state})
|
||||
self.last_state = state
|
||||
except: pass
|
||||
|
||||
def gui_update_loop(self):
|
||||
if self.running:
|
||||
self.update_stats()
|
||||
self.root.after(100, self.gui_update_loop)
|
||||
|
||||
def update_stats(self):
|
||||
if not hasattr(self, 'lbl_time') or not self.lbl_time.winfo_exists(): return
|
||||
vals = self.current_values
|
||||
self.lbl_time.config(text=f"{int(vals['Time'])}")
|
||||
self.lbl_roll.config(text=f"{vals['Roll']:.2f}")
|
||||
self.lbl_rate.config(text=f"{vals['Rate']:.2f}")
|
||||
self.lbl_out.config(text=f"{vals['Output']:.2f}")
|
||||
self.lbl_skew.config(text=f"{vals['Skew']:.2f}")
|
||||
|
||||
# New Environment UI Updates
|
||||
if hasattr(self, 'lbl_alt'):
|
||||
self.lbl_alt.config(text=f"{vals['Alt']:.1f}")
|
||||
self.lbl_lat.config(text=f"{vals['Lat']:.6f}")
|
||||
self.lbl_lon.config(text=f"{vals['Lon']:.6f}")
|
||||
|
||||
# Update GPS Status Bubble
|
||||
gps_state = vals.get("GPS_State", 0)
|
||||
if gps_state == 0:
|
||||
self.gps_canvas.itemconfig(self.gps_dot, fill="red", outline="red")
|
||||
self.lbl_gps_text.config(text="NO NMEA", foreground="red")
|
||||
elif gps_state == 1:
|
||||
self.gps_canvas.itemconfig(self.gps_dot, fill="orange", outline="orange")
|
||||
self.lbl_gps_text.config(text="SEARCHING", foreground="orange")
|
||||
elif gps_state == 2:
|
||||
self.gps_canvas.itemconfig(self.gps_dot, fill="green", outline="green")
|
||||
self.lbl_gps_text.config(text="FIX ACQUIRED", foreground="green")
|
||||
|
||||
state = vals["State"]
|
||||
self.lbl_state_text.config(text=state)
|
||||
self.lbl_active_pid.config(text=f"Kp: {vals['ActiveKp']:.3f} | Kd: {vals['ActiveKd']:.3f}")
|
||||
|
||||
color_map = {"IDLE": "gray", "ARMED": "#F4D03F", "IGNITING": "#FF8C00", "FLIGHT": "green", "DISCONNECTED": "gray"}
|
||||
dot_color = color_map.get(state, "gray")
|
||||
self.state_canvas.itemconfig(self.state_dot, fill=dot_color, outline=dot_color)
|
||||
|
||||
def update_plot(self, frame):
|
||||
if not self.time_data: return self.line_roll, self.line_rate
|
||||
|
||||
t, roll, rate = list(self.time_data)[-VIEW_POINTS:], list(self.roll_data)[-VIEW_POINTS:], list(self.rate_data)[-VIEW_POINTS:]
|
||||
out = np.array(list(self.output_data)[-VIEW_POINTS:])
|
||||
rate_plot = [r * RATE_SCALE for r in rate]
|
||||
|
||||
self.line_roll.set_data(t, roll)
|
||||
self.line_rate.set_data(t, rate_plot)
|
||||
for collection in self.ax.collections: collection.remove()
|
||||
self.ax.fill_between(t, out, 0, where=(out >= 0), interpolate=True, color='green', alpha=0.3)
|
||||
self.ax.fill_between(t, out, 0, where=(out < 0), interpolate=True, color='red', alpha=0.3)
|
||||
self.ax.set_xlim(min(t), max(t) + 1)
|
||||
|
||||
limit = 10.0
|
||||
all_y = roll + rate_plot + list(out)
|
||||
if all_y and max(abs(y) for y in all_y) > limit: limit = max(abs(y) for y in all_y) * 1.1
|
||||
self.ax.set_ylim(-limit, limit)
|
||||
return self.line_roll, self.line_rate
|
||||
|
||||
def reset_dashboard(self):
|
||||
self.time_data.clear(); self.roll_data.clear(); self.rate_data.clear(); self.output_data.clear()
|
||||
self.mission_events.clear()
|
||||
self.current_values = {"Time": 0, "Roll": 0.0, "Rate": 0.0, "Output": 0.0, "State": "DISCONNECTED", "ActiveKp": 0.0, "ActiveKd": 0.0, "Skew": 0.0, "Lat": 0.0, "Lon": 0.0, "Alt": 0.0, "GPS_State": 0}
|
||||
self.last_state = "DISCONNECTED"
|
||||
self.update_stats()
|
||||
|
||||
def save_graph(self):
|
||||
if hasattr(self, 'anim') and self.anim.event_source:
|
||||
self.anim.event_source.stop()
|
||||
|
||||
file_path = filedialog.asksaveasfilename(
|
||||
defaultextension=".png",
|
||||
filetypes=[("PNG Image", "*.png"), ("All Files", "*.*")],
|
||||
title="Save Telemetry Graph"
|
||||
)
|
||||
|
||||
if file_path:
|
||||
try:
|
||||
data_len = len(self.time_data)
|
||||
width = max(12, min(200, data_len / 50)) if data_len > 0 else 12
|
||||
save_fig, save_ax = plt.subplots(figsize=(width, 4), dpi=100)
|
||||
|
||||
t = list(self.time_data)
|
||||
roll = list(self.roll_data)
|
||||
rate = [r * RATE_SCALE for r in self.rate_data]
|
||||
out = np.array(self.output_data)
|
||||
|
||||
save_ax.plot(t, roll, label='Roll Angle', color='tab:blue', linewidth=2)
|
||||
save_ax.plot(t, rate, label=f'Roll Rate (x{RATE_SCALE})', color='tab:orange', linewidth=1.5)
|
||||
|
||||
save_ax.fill_between(t, out, 0, where=(out >= 0), interpolate=True, color='green', alpha=0.3)
|
||||
save_ax.fill_between(t, out, 0, where=(out < 0), interpolate=True, color='red', alpha=0.3)
|
||||
|
||||
y_max = max(max(roll) if roll else 10, max(rate) if rate else 10) * 0.9
|
||||
for event in self.mission_events:
|
||||
ev_time = event["time"]
|
||||
ev_name = event["state"]
|
||||
save_ax.axvline(x=ev_time, color='black', linestyle='--', alpha=0.6)
|
||||
save_ax.text(ev_time, y_max, f" {ev_name}", rotation=90, verticalalignment='top', fontsize=9, fontweight='bold', color='black')
|
||||
|
||||
save_ax.set_title(f"Rocket Flight Data - {len(t)} points")
|
||||
save_ax.legend()
|
||||
|
||||
if t: save_ax.set_xlim(min(t), max(t) + 1)
|
||||
|
||||
save_fig.savefig(file_path, dpi=100, bbox_inches='tight')
|
||||
plt.close(save_fig)
|
||||
messagebox.showinfo("Success", "Graph saved successfully.")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error saving graph: {e}")
|
||||
|
||||
if hasattr(self, 'anim') and self.anim.event_source:
|
||||
self.anim.event_source.start()
|
||||
|
||||
def on_close(self):
|
||||
self.running = False
|
||||
self.root.destroy()
|
||||
|
||||
if __name__ == "__main__":
|
||||
root = tk.Tk()
|
||||
app = TelemetryApp(root)
|
||||
root.protocol("WM_DELETE_WINDOW", app.on_close)
|
||||
root.mainloop()
|
||||
Reference in New Issue
Block a user