Compare commits
32 Commits
71e2214e32
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a46496d688 | ||
|
|
47b3427e63 | ||
|
|
12a0d58800 | ||
| 629c735eec | |||
|
|
992270ed00 | ||
|
|
83af09b47c | ||
|
|
0c342d7989 | ||
|
|
58a523aab2 | ||
|
|
896ba322c0 | ||
|
|
9173c3b93a | ||
|
|
f2c69587ee | ||
|
|
324cd5dddc | ||
|
|
bc53bd7e82 | ||
|
|
477fd698dc | ||
|
|
7301149c47 | ||
|
|
b7cf38c649 | ||
|
|
ceb9610bca | ||
|
|
7d8f813b59 | ||
|
|
8044bbde94 | ||
|
|
4a830dde91 | ||
|
|
64ce2472ab | ||
|
|
952a42b3e9 | ||
|
|
5cb0be0aaa | ||
|
|
18fbc63281 | ||
|
|
83cc6bed19 | ||
|
|
f7f0af92dd | ||
|
|
c1a2994d00 | ||
|
|
f1ed809c71 | ||
|
|
4e68dcef5f | ||
|
|
f610f0fed2 | ||
|
|
559e62e292 | ||
|
|
7a6e69861b |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -65,6 +65,8 @@ scripts/*.pyo
|
||||
*.pyc
|
||||
*.pyo
|
||||
__pycache__/
|
||||
.venv/
|
||||
uv.lock
|
||||
|
||||
|
||||
# extra resources
|
||||
|
||||
5
IDEAS.md
Normal file
5
IDEAS.md
Normal file
@@ -0,0 +1,5 @@
|
||||
Note to keep inspirations lest I forget.
|
||||
|
||||
# Things to do, but not really urgent
|
||||
- Fit OpenStreetMap somewhere and have a proper map widget in UI (not really navs, just show where I am)
|
||||
- Integrate paho-mqtt into Python backend for some telemetry. Also set up mosquitto or whatnots on vps.
|
||||
3
arduino/.gitignore
vendored
Normal file
3
arduino/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# arduino test files
|
||||
|
||||
test/
|
||||
81
arduino/IMU.md
Normal file
81
arduino/IMU.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# WT61 IMU Quick Reference
|
||||
|
||||
6-axis IMU (accelerometer + gyroscope) with onboard angle calculation via Kalman filter.
|
||||
|
||||
## Serial Configuration
|
||||
|
||||
| Setting | Factory Default | Our Config |
|
||||
|---------|-----------------|------------|
|
||||
| Baud rate | 115200 | 115200 |
|
||||
| Output rate | 100Hz | 100Hz |
|
||||
|
||||
**Config commands** (sent on init):
|
||||
```
|
||||
0xFF 0xAA 0x66 # Vertical mounting mode
|
||||
0xFF 0xAA 0x63 # 115200 baud / 100Hz
|
||||
```
|
||||
Settings are saved to flash - persist across power cycles.
|
||||
|
||||
**Fallback to 9600/20Hz:** If 115200 causes packet loss on AltSoftSerial, change `0x63` to `0x64` in `imu.cpp` and add `imuSerial.begin(9600)` after the delay.
|
||||
|
||||
## Wiring (ATmega328P / AltSoftSerial)
|
||||
|
||||
| WT61 Pin | Arduino Pin | Notes |
|
||||
|----------|-------------|-------|
|
||||
| TX | 8 (RX) | AltSoftSerial fixed pin |
|
||||
| RX | 9 (TX) | Only needed for config commands |
|
||||
| VCC | 5V | |
|
||||
| GND | GND | |
|
||||
|
||||
## Packet Structure
|
||||
|
||||
11 bytes per packet, continuous stream (3 packet types interleaved):
|
||||
|
||||
```
|
||||
Byte 0: 0x55 (header)
|
||||
Byte 1: Packet type
|
||||
Bytes 2-3: Value 0 (int16_t, little-endian)
|
||||
Bytes 4-5: Value 1
|
||||
Bytes 6-7: Value 2
|
||||
Bytes 8-9: Temperature (usually ignored)
|
||||
Byte 10: Checksum (sum of bytes 0-9, lower 8 bits)
|
||||
```
|
||||
|
||||
### Packet Types
|
||||
|
||||
| Type | Byte 1 | V0 | V1 | V2 |
|
||||
|------|--------|----|----|-----|
|
||||
| Acceleration | 0x51 | ax | ay | az |
|
||||
| Gyroscope | 0x52 | gx | gy | gz |
|
||||
| Angle | 0x53 | roll | pitch | yaw |
|
||||
|
||||
## Scale Factors
|
||||
|
||||
| Measurement | Formula | Range |
|
||||
|-------------|---------|-------|
|
||||
| Acceleration | `raw / 32768.0 * 16.0` | +/-16g |
|
||||
| Gyroscope | `raw / 32768.0 * 2000.0` | +/-2000 deg/s |
|
||||
| Angle | `raw / 32768.0 * 180.0` | +/-180 deg |
|
||||
| Temperature | `raw / 340.0 + 36.25` | Celsius |
|
||||
|
||||
## Known Quirks
|
||||
|
||||
- **Boot time**: Module needs ~200-500ms after power-on before sending valid data
|
||||
- **Config at wrong baud**: Commands sent at wrong baud rate are ignored (garbled bytes) - this is actually useful for idempotent config-on-boot
|
||||
- **AltSoftSerial at 115200**: Technically out of spec for 16MHz AVR, but TX-only bursts of a few bytes work fine. Don't try sustained RX at that rate.
|
||||
|
||||
## Commands
|
||||
|
||||
All commands are 3 bytes: `0xFF 0xAA <data>`
|
||||
|
||||
| Data Byte | Function | Notes |
|
||||
|-----------|----------|-------|
|
||||
| 0x52 | Zero Z-axis angle | Resets yaw to 0 |
|
||||
| 0x67 | Accelerometer calibration | Keep module level, zeros X/Y |
|
||||
| 0x60 | Toggle sleep mode | Toggles between standby and active |
|
||||
| 0x61 | Serial mode | Enable UART, disable I2C |
|
||||
| 0x62 | I2C mode | Enable I2C, disable UART |
|
||||
| 0x63 | 115200 baud / 100Hz | Factory default |
|
||||
| 0x64 | 9600 baud / 20Hz | Our config |
|
||||
| 0x65 | Horizontal mounting | Module placed flat |
|
||||
| 0x66 | Vertical mounting | Module placed upright |
|
||||
56
arduino/PROTOCOL.md
Normal file
56
arduino/PROTOCOL.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Arduino-Pi Communication Protocol
|
||||
|
||||
Telemetry protocol for Arduino → Pi communication over UART at 115200 baud.
|
||||
|
||||
## Design Rationale
|
||||
|
||||
- **ASCII-based**: Human-debuggable, digits are self-bounding (no accidental header spoofing)
|
||||
- **TSV format**: Tab delimiter, predictable field count, trivial to parse, survives floating decimals
|
||||
- **Null-terminated**: `\0` (0x00) is unambiguous end-of-line, avoids CRLF headaches
|
||||
- **10Hz rate**: ~50 bytes/line × 10Hz = 500 B/s, well under 115200 baud capacity (~4% utilization)
|
||||
|
||||
## Telemetry Frame (Arduino → Pi)
|
||||
|
||||
```
|
||||
field0\tfield1\tfield2\t...\tfieldN\0
|
||||
```
|
||||
|
||||
### Fields
|
||||
|
||||
| Index | Name | Unit | Description |
|
||||
|-------|--------|---------|--------------------------------|
|
||||
| 0 | V_bat | V | Battery voltage |
|
||||
| 1 | Ax | g | Acceleration X |
|
||||
| 2 | Ay | g | Acceleration Y |
|
||||
| 3 | Az | g | Acceleration Z |
|
||||
| 4 | Gx | deg/s | Angular velocity X |
|
||||
| 5 | Gy | deg/s | Angular velocity Y |
|
||||
| 6 | Gz | deg/s | Angular velocity Z |
|
||||
| 7 | Roll | deg | Euler angle roll |
|
||||
| 8 | Pitch | deg | Euler angle pitch |
|
||||
| 9 | Yaw | deg | Euler angle yaw |
|
||||
| 10 | RPM | RPM | Engine RPM |
|
||||
| 11 | Gear | - | Gear position (0=N, 1-6) |
|
||||
|
||||
### Example
|
||||
|
||||
```
|
||||
12.45\t0.02\t-0.01\t1.00\t0.50\t-0.25\t0.10\t2.35\t-1.20\t45.80\t3500\t3\0
|
||||
```
|
||||
|
||||
## Stale Data Handling
|
||||
|
||||
When IMU data is stale, empty fields are sent to preserve field count:
|
||||
```
|
||||
12.45\t\t\t\t\t\t\t\t\t\0
|
||||
```
|
||||
Backend parses empty fields as null/NaN.
|
||||
|
||||
## Commands (Pi → Arduino)
|
||||
|
||||
TBD: Command structure for configuration, calibration triggers, etc.
|
||||
|
||||
## Versioning
|
||||
|
||||
Protocol changes should bump a version field or use a different frame header.
|
||||
Currently unversioned (v0 / development).
|
||||
@@ -11,26 +11,45 @@ Sensor interface running on Arduino Nano, communicating with Pi via UART.
|
||||
## Current Capabilities
|
||||
|
||||
- Battery voltage monitoring (voltage divider on A0)
|
||||
- Serial output at 9600 baud, 1Hz update rate
|
||||
- WT61 IMU/gyro via AltSoftSerial (9-axis: accel, gyro, euler angles)
|
||||
- Duplex UART to Pi at 115200 baud, 10Hz telemetry output
|
||||
- Simple text-based protocol for easy debugging
|
||||
|
||||
## Planned
|
||||
## Dependencies
|
||||
|
||||
- RPM sensing (pulse counting from ignition coil)
|
||||
- Engine temperature (thermocouple/NTC)
|
||||
- Gear position indicator
|
||||
- Turn signal / high beam status
|
||||
Install via Arduino Library Manager:
|
||||
- **AltSoftSerial** by Paul Stoffregen - for WT61 IMU serial
|
||||
|
||||
## Pin Assignments
|
||||
|
||||
| Pin | Function |
|
||||
|-----|----------|
|
||||
| A0 | Battery voltage (via divider) |
|
||||
| D0 (RX) | Pi UART RX ← Arduino TX |
|
||||
| D1 (TX) | Pi UART TX → Arduino RX |
|
||||
| D8 | WT61 IMU RX (AltSoftSerial) |
|
||||
| D9 | WT61 IMU TX (unused, AltSoftSerial fixed pin) |
|
||||
| D13 | Status LED (heartbeat) |
|
||||
|
||||
## Hardware
|
||||
|
||||
- **MCU**: Arduino Nano (ATmega328P)
|
||||
- **Connection**: UART to Pi GPIO (TX→RX, RX→TX, common GND)
|
||||
- **Voltage sensing**: Resistor divider scaled for 0-20V input range
|
||||
- **Pi Connection**: UART at 115200 baud (TX→RX, RX→TX, common GND)
|
||||
- **IMU**: WT61 module at 9600 baud, 20Hz output
|
||||
- **Voltage sensing**: Resistor divider (100k/47k) scaled for 0-20V input
|
||||
|
||||
## Protocol
|
||||
|
||||
Simple text-based for now:
|
||||
```
|
||||
V_bat: 12.45V
|
||||
```
|
||||
TSV (tab-separated), null-terminated frames at 10Hz. See [PROTOCOL.md](PROTOCOL.md) for full specification.
|
||||
|
||||
Future: structured binary or JSON for multiple sensors.
|
||||
## Planned
|
||||
|
||||
- RPM sensing (pulse counting from ignition coil)
|
||||
- Gear position indicator
|
||||
|
||||
### Not planned
|
||||
|
||||
- Engine temperature (thermocouple/NTC)
|
||||
*Borderline do not want to do, simple but not really useful*
|
||||
- Turn signal / high beam status
|
||||
*No need to do something the dash already does*
|
||||
|
||||
113
arduino/main/comms.cpp
Normal file
113
arduino/main/comms.cpp
Normal file
@@ -0,0 +1,113 @@
|
||||
#include "comms.h"
|
||||
|
||||
// Pi communication uses hardware Serial (pins 0/1)
|
||||
// Baud rate - 115200 is reasonable for duplex with Pi
|
||||
static const long BAUD_RATE = 115200;
|
||||
|
||||
// Command buffer
|
||||
static const int CMD_BUF_SIZE = 64;
|
||||
static char cmdBuf[CMD_BUF_SIZE];
|
||||
static int cmdIndex = 0;
|
||||
static bool cmdReady = false;
|
||||
|
||||
// Connection tracking
|
||||
static unsigned long lastRxTime = 0;
|
||||
|
||||
void comms_init() {
|
||||
Serial.begin(BAUD_RATE);
|
||||
cmdIndex = 0;
|
||||
cmdReady = false;
|
||||
}
|
||||
|
||||
bool comms_update() {
|
||||
while (Serial.available()) {
|
||||
char c = Serial.read();
|
||||
lastRxTime = millis();
|
||||
|
||||
if (c == '\n' || c == '\r') {
|
||||
if (cmdIndex > 0) {
|
||||
cmdBuf[cmdIndex] = '\0';
|
||||
cmdReady = true;
|
||||
cmdIndex = 0;
|
||||
return true;
|
||||
}
|
||||
} else if (cmdIndex < CMD_BUF_SIZE - 1) {
|
||||
cmdBuf[cmdIndex++] = c;
|
||||
}
|
||||
// else: overflow, silently drop extra chars
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void comms_send_telemetry(float voltage, const ImuData& imu, bool imu_valid, int rpm, int gear) {
|
||||
// Field 0: voltage
|
||||
Serial.print(voltage, 2);
|
||||
Serial.write('\t');
|
||||
|
||||
if (imu_valid) {
|
||||
// Fields 1-3: acceleration
|
||||
Serial.print(imu.ax, 2);
|
||||
Serial.write('\t');
|
||||
Serial.print(imu.ay, 2);
|
||||
Serial.write('\t');
|
||||
Serial.print(imu.az, 2);
|
||||
Serial.write('\t');
|
||||
|
||||
// Fields 4-6: angular velocity
|
||||
Serial.print(imu.gx, 2);
|
||||
Serial.write('\t');
|
||||
Serial.print(imu.gy, 2);
|
||||
Serial.write('\t');
|
||||
Serial.print(imu.gz, 2);
|
||||
Serial.write('\t');
|
||||
|
||||
// Fields 7-9: euler angles
|
||||
Serial.print(imu.roll, 2);
|
||||
Serial.write('\t');
|
||||
Serial.print(imu.pitch, 2);
|
||||
Serial.write('\t');
|
||||
Serial.print(imu.yaw, 2);
|
||||
} else {
|
||||
// Empty fields for stale IMU (9 tabs for 9 empty fields)
|
||||
Serial.print(F("\t\t\t\t\t\t\t\t"));
|
||||
}
|
||||
|
||||
// Fields 10-11: RPM and gear
|
||||
Serial.write('\t');
|
||||
Serial.print(rpm);
|
||||
Serial.write('\t');
|
||||
Serial.print(gear);
|
||||
|
||||
// Null terminator (no newline)
|
||||
Serial.write('\0');
|
||||
}
|
||||
|
||||
void comms_send(const char* key, float value, int decimals) {
|
||||
Serial.print(key);
|
||||
Serial.print(": ");
|
||||
Serial.println(value, decimals);
|
||||
}
|
||||
|
||||
void comms_send(const char* key, int value) {
|
||||
Serial.print(key);
|
||||
Serial.print(": ");
|
||||
Serial.println(value);
|
||||
}
|
||||
|
||||
void comms_send(const char* key, const char* value) {
|
||||
Serial.print(key);
|
||||
Serial.print(": ");
|
||||
Serial.println(value);
|
||||
}
|
||||
|
||||
const char* comms_get_command() {
|
||||
if (cmdReady) {
|
||||
cmdReady = false;
|
||||
return cmdBuf;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
bool comms_is_connected(unsigned long timeout_ms) {
|
||||
return (millis() - lastRxTime) < timeout_ms;
|
||||
}
|
||||
31
arduino/main/comms.h
Normal file
31
arduino/main/comms.h
Normal file
@@ -0,0 +1,31 @@
|
||||
#ifndef COMMS_H
|
||||
#define COMMS_H
|
||||
|
||||
#include <Arduino.h>
|
||||
#include "imu.h"
|
||||
|
||||
// Initialize Pi serial communication (call in setup)
|
||||
void comms_init();
|
||||
|
||||
// Process incoming commands from Pi - call in loop
|
||||
// Returns true if a complete command was received
|
||||
bool comms_update();
|
||||
|
||||
// Send complete telemetry frame (TSV format, null-terminated)
|
||||
// Format: V_bat\tAx\tAy\tAz\tGx\tGy\tGz\tRoll\tPitch\tYaw\tRPM\tGear\0
|
||||
// If imu_valid is false, IMU fields are empty (but tabs preserved)
|
||||
void comms_send_telemetry(float voltage, const ImuData& imu, bool imu_valid, int rpm, int gear);
|
||||
|
||||
// Send key:value line (for debug/ACK, newline-terminated)
|
||||
void comms_send(const char* key, float value, int decimals = 2);
|
||||
void comms_send(const char* key, int value);
|
||||
void comms_send(const char* key, const char* value);
|
||||
|
||||
// Get last received command (empty if none)
|
||||
// Command buffer is cleared after reading
|
||||
const char* comms_get_command();
|
||||
|
||||
// Check if connected (received any data recently)
|
||||
bool comms_is_connected(unsigned long timeout_ms = 5000);
|
||||
|
||||
#endif
|
||||
19
arduino/main/gear.cpp
Normal file
19
arduino/main/gear.cpp
Normal file
@@ -0,0 +1,19 @@
|
||||
#include "gear.h"
|
||||
|
||||
// Mock gear: derived from RPM bands
|
||||
// Real sensor would read position switch
|
||||
|
||||
void gear_init() {
|
||||
// Nothing to init for mock
|
||||
}
|
||||
|
||||
int gear_get(int rpm) {
|
||||
// Simulate gear based on RPM
|
||||
// N < 1000, 1st < 2500, 2nd < 4000, 3rd < 5500, 4th < 7000, 5th+
|
||||
if (rpm < 1000) return 0; // Neutral
|
||||
if (rpm < 2500) return 1;
|
||||
if (rpm < 4000) return 2;
|
||||
if (rpm < 5500) return 3;
|
||||
if (rpm < 7000) return 4;
|
||||
return 5;
|
||||
}
|
||||
7
arduino/main/gear.h
Normal file
7
arduino/main/gear.h
Normal file
@@ -0,0 +1,7 @@
|
||||
#ifndef GEAR_H
|
||||
#define GEAR_H
|
||||
|
||||
void gear_init();
|
||||
int gear_get(int rpm); // Returns gear 0-6 (0=neutral)
|
||||
|
||||
#endif
|
||||
215
arduino/main/imu.cpp
Normal file
215
arduino/main/imu.cpp
Normal file
@@ -0,0 +1,215 @@
|
||||
#include "imu.h"
|
||||
#include <AltSoftSerial.h>
|
||||
|
||||
// AltSoftSerial uses fixed pins on ATmega328P:
|
||||
// RX = Pin 8, TX = Pin 9
|
||||
static AltSoftSerial imuSerial;
|
||||
|
||||
// WT61 packet structure:
|
||||
// Byte 0: 0x55 (header)
|
||||
// Byte 1: Packet type (0x51=accel, 0x52=gyro, 0x53=angle)
|
||||
// Bytes 2-9: Data (4x int16_t, little-endian)
|
||||
// Byte 10: Checksum (sum of bytes 0-9, lower 8 bits)
|
||||
static const uint8_t PACKET_HEADER = 0x55;
|
||||
static const uint8_t PACKET_ACCEL = 0x51;
|
||||
static const uint8_t PACKET_GYRO = 0x52;
|
||||
static const uint8_t PACKET_ANGLE = 0x53;
|
||||
static const int PACKET_SIZE = 11;
|
||||
|
||||
// Receive buffer
|
||||
static uint8_t rxBuf[PACKET_SIZE];
|
||||
static int rxIndex = 0;
|
||||
|
||||
// Latest data
|
||||
static ImuData currentData = {0};
|
||||
|
||||
// Calibration offsets and state
|
||||
static ImuData offsets = {0};
|
||||
static bool calibrated = false;
|
||||
|
||||
// Scale factors from WT61 datasheet
|
||||
// Accel: raw / 32768 * 16g
|
||||
// Gyro: raw / 32768 * 2000 deg/s
|
||||
// Angle: raw / 32768 * 180 deg
|
||||
static const float ACCEL_SCALE = 16.0 / 32768.0;
|
||||
static const float GYRO_SCALE = 2000.0 / 32768.0;
|
||||
static const float ANGLE_SCALE = 180.0 / 32768.0;
|
||||
|
||||
static int16_t parseI16(uint8_t lo, uint8_t hi) {
|
||||
return (int16_t)((hi << 8) | lo);
|
||||
}
|
||||
|
||||
static bool validateChecksum() {
|
||||
uint8_t sum = 0;
|
||||
for (int i = 0; i < PACKET_SIZE - 1; i++) {
|
||||
sum += rxBuf[i];
|
||||
}
|
||||
return sum == rxBuf[PACKET_SIZE - 1];
|
||||
}
|
||||
|
||||
static void processPacket() {
|
||||
if (!validateChecksum()) {
|
||||
return; // Bad packet, ignore
|
||||
}
|
||||
|
||||
uint8_t type = rxBuf[1];
|
||||
int16_t v0 = parseI16(rxBuf[2], rxBuf[3]);
|
||||
int16_t v1 = parseI16(rxBuf[4], rxBuf[5]);
|
||||
int16_t v2 = parseI16(rxBuf[6], rxBuf[7]);
|
||||
// v3 at bytes 8-9 is temperature, ignored for now
|
||||
|
||||
switch (type) {
|
||||
case PACKET_ACCEL:
|
||||
currentData.ax = v0 * ACCEL_SCALE;
|
||||
currentData.ay = v1 * ACCEL_SCALE;
|
||||
currentData.az = v2 * ACCEL_SCALE;
|
||||
break;
|
||||
case PACKET_GYRO:
|
||||
currentData.gx = v0 * GYRO_SCALE;
|
||||
currentData.gy = v1 * GYRO_SCALE;
|
||||
currentData.gz = v2 * GYRO_SCALE;
|
||||
break;
|
||||
case PACKET_ANGLE:
|
||||
currentData.roll = v0 * ANGLE_SCALE;
|
||||
currentData.pitch = v1 * ANGLE_SCALE;
|
||||
currentData.yaw = v2 * ANGLE_SCALE;
|
||||
currentData.lastUpdate = millis();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void imu_init() {
|
||||
// Configure WT61 at 115200 - stays there (no baud switch)
|
||||
// See IMU.md for command reference
|
||||
imuSerial.begin(115200);
|
||||
|
||||
imu_send_cmd(0x52); // Reset yaw (for the sake of it)
|
||||
delay(50);
|
||||
imu_send_cmd(0x65); // Flat mounting mode
|
||||
delay(50);
|
||||
imu_send_cmd(0x64); // 9600 bauds / 20Hz report
|
||||
delay(50); // Let WT61 process config
|
||||
|
||||
// Revert to 9600 bauds
|
||||
imuSerial.begin(9600);
|
||||
|
||||
// In case WT61 already is at 9600
|
||||
imu_send_cmd(0x52); // Reset yaw (for the sake of it)
|
||||
delay(50);
|
||||
imu_send_cmd(0x65); // Flat mounting mode
|
||||
delay(50);
|
||||
imu_send_cmd(0x64); // 9600 bauds / 20Hz report
|
||||
delay(50); // Let WT61 process config
|
||||
|
||||
|
||||
rxIndex = 0;
|
||||
currentData = {0};
|
||||
}
|
||||
|
||||
bool imu_update() {
|
||||
bool gotPacket = false;
|
||||
|
||||
while (imuSerial.available()) {
|
||||
uint8_t c = imuSerial.read();
|
||||
|
||||
// State machine: look for header, then collect packet
|
||||
if (rxIndex == 0) {
|
||||
if (c == PACKET_HEADER) {
|
||||
rxBuf[rxIndex++] = c;
|
||||
}
|
||||
// else: discard, wait for sync
|
||||
} else {
|
||||
rxBuf[rxIndex++] = c;
|
||||
|
||||
if (rxIndex >= PACKET_SIZE) {
|
||||
processPacket();
|
||||
rxIndex = 0;
|
||||
gotPacket = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return gotPacket;
|
||||
}
|
||||
|
||||
const ImuData& imu_get_data() {
|
||||
// Apply calibration offsets if calibrated
|
||||
// Using a static to avoid creating new struct each call
|
||||
static ImuData calibratedData;
|
||||
|
||||
if (calibrated) {
|
||||
calibratedData.ax = currentData.ax - offsets.ax;
|
||||
calibratedData.ay = currentData.ay - offsets.ay;
|
||||
calibratedData.az = currentData.az - offsets.az;
|
||||
calibratedData.gx = currentData.gx - offsets.gx;
|
||||
calibratedData.gy = currentData.gy - offsets.gy;
|
||||
calibratedData.gz = currentData.gz - offsets.gz;
|
||||
calibratedData.roll = currentData.roll - offsets.roll;
|
||||
calibratedData.pitch = currentData.pitch - offsets.pitch;
|
||||
calibratedData.yaw = currentData.yaw - offsets.yaw;
|
||||
calibratedData.lastUpdate = currentData.lastUpdate;
|
||||
return calibratedData;
|
||||
}
|
||||
|
||||
return currentData;
|
||||
}
|
||||
|
||||
bool imu_is_fresh(unsigned long timeout_ms) {
|
||||
return (millis() - currentData.lastUpdate) < timeout_ms;
|
||||
}
|
||||
|
||||
void imu_calibrate() {
|
||||
const int SAMPLES = 5; // ~250ms at 20Hz IMU rate
|
||||
|
||||
// Accumulators for averaging
|
||||
float sum_ax = 0, sum_ay = 0, sum_az = 0;
|
||||
float sum_gx = 0, sum_gy = 0, sum_gz = 0;
|
||||
float sum_roll = 0, sum_pitch = 0, sum_yaw = 0;
|
||||
|
||||
int count = 0;
|
||||
unsigned long lastUpdate = currentData.lastUpdate;
|
||||
|
||||
// Block until we've collected enough samples
|
||||
while (count < SAMPLES) {
|
||||
imu_update();
|
||||
|
||||
if (currentData.lastUpdate != lastUpdate) {
|
||||
// New angle packet arrived (lastUpdate only changes on angle packets)
|
||||
sum_ax += currentData.ax;
|
||||
sum_ay += currentData.ay;
|
||||
sum_az += currentData.az;
|
||||
sum_gx += currentData.gx;
|
||||
sum_gy += currentData.gy;
|
||||
sum_gz += currentData.gz;
|
||||
sum_roll += currentData.roll;
|
||||
sum_pitch += currentData.pitch;
|
||||
sum_yaw += currentData.yaw;
|
||||
|
||||
lastUpdate = currentData.lastUpdate;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
// Store averaged offsets
|
||||
offsets.ax = sum_ax / SAMPLES;
|
||||
offsets.ay = sum_ay / SAMPLES;
|
||||
offsets.az = sum_az / SAMPLES;
|
||||
offsets.gx = sum_gx / SAMPLES;
|
||||
offsets.gy = sum_gy / SAMPLES;
|
||||
offsets.gz = sum_gz / SAMPLES;
|
||||
offsets.roll = sum_roll / SAMPLES;
|
||||
offsets.pitch = sum_pitch / SAMPLES;
|
||||
offsets.yaw = sum_yaw / SAMPLES;
|
||||
|
||||
calibrated = true;
|
||||
}
|
||||
|
||||
bool imu_is_calibrated() {
|
||||
return calibrated;
|
||||
}
|
||||
|
||||
void imu_send_cmd(uint8_t cmd) {
|
||||
const uint8_t packet[] = {0xFF, 0xAA, cmd};
|
||||
imuSerial.write(packet, 3);
|
||||
imuSerial.flush();
|
||||
}
|
||||
50
arduino/main/imu.h
Normal file
50
arduino/main/imu.h
Normal file
@@ -0,0 +1,50 @@
|
||||
#ifndef IMU_H
|
||||
#define IMU_H
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
// WT61 IMU data structure
|
||||
struct ImuData {
|
||||
// Acceleration (g)
|
||||
float ax, ay, az;
|
||||
// Angular velocity (deg/s)
|
||||
float gx, gy, gz;
|
||||
// Euler angles (degrees)
|
||||
float roll, pitch, yaw;
|
||||
// Timestamp of last valid packet (millis)
|
||||
unsigned long lastUpdate;
|
||||
};
|
||||
|
||||
// Initialize IMU serial (call in setup)
|
||||
void imu_init();
|
||||
|
||||
// Process incoming bytes - call frequently in loop
|
||||
// Returns true if new complete packet was parsed
|
||||
bool imu_update();
|
||||
|
||||
// Get latest IMU data
|
||||
const ImuData& imu_get_data();
|
||||
|
||||
// Check if IMU data is fresh (updated within timeout_ms)
|
||||
bool imu_is_fresh(unsigned long timeout_ms = 200);
|
||||
|
||||
// Calibrate IMU - blocks for ~250ms while sampling
|
||||
// Sets current orientation as zero reference
|
||||
// Note: Zeroes all axes including accel (loses gravity reference)
|
||||
// Revisit once mounting orientation is finalized
|
||||
void imu_calibrate();
|
||||
|
||||
// Check if calibration has been performed
|
||||
bool imu_is_calibrated();
|
||||
|
||||
// Send command to IMU (see IMU.md for command list)
|
||||
// Common commands: 0x52 = zero yaw, 0x67 = calibrate accel
|
||||
void imu_send_cmd(uint8_t cmd);
|
||||
|
||||
// Convenience: zero the yaw angle
|
||||
inline void imu_zero_yaw() { imu_send_cmd(0x52); }
|
||||
|
||||
// Convenience: calibrate accelerometer (keep module level!)
|
||||
inline void imu_calibrate_accel() { imu_send_cmd(0x67); }
|
||||
|
||||
#endif
|
||||
@@ -2,24 +2,82 @@
|
||||
// Motorcycle telemetry hub
|
||||
|
||||
#include "voltage.h"
|
||||
#include "imu.h"
|
||||
#include "rpm.h"
|
||||
#include "gear.h"
|
||||
#include "comms.h"
|
||||
|
||||
// Timing
|
||||
static const unsigned long TELEMETRY_INTERVAL_MS = 100; // 10Hz telemetry
|
||||
static unsigned long lastTelemetryTime = 0;
|
||||
|
||||
void setup() {
|
||||
Serial.begin(9600);
|
||||
pinMode(LED_BUILTIN, OUTPUT);
|
||||
|
||||
comms_init(); // Hardware Serial first so we can debug
|
||||
Serial.println(F("[INIT] comms ok"));
|
||||
|
||||
voltage_init();
|
||||
Serial.println(F("[INIT] voltage ok"));
|
||||
|
||||
imu_init(); // AltSoftSerial on pins 8(RX)/9(TX)
|
||||
Serial.println(F("[INIT] imu ok"));
|
||||
|
||||
rpm_init();
|
||||
gear_init();
|
||||
Serial.println(F("[INIT] rpm/gear ok"));
|
||||
|
||||
// Let IMU warm up a bit before calibrating
|
||||
// (WT61 needs a moment to stabilize after power-on)
|
||||
delay(500);
|
||||
|
||||
Serial.println(F("[INIT] calibrating..."));
|
||||
// Zero calibration - current position becomes reference
|
||||
// Blocks for ~250ms while sampling
|
||||
imu_calibrate();
|
||||
Serial.println(F("[INIT] calibration done, entering loop"));
|
||||
}
|
||||
|
||||
void loop() {
|
||||
// Report battery voltage
|
||||
Serial.print("V_bat: ");
|
||||
Serial.print(voltage_read(), 2);
|
||||
Serial.println("V");
|
||||
// Always poll IMU - it's streaming at 20Hz
|
||||
imu_update();
|
||||
|
||||
// Heartbeat blink
|
||||
digitalWrite(LED_BUILTIN, HIGH);
|
||||
delay(50);
|
||||
digitalWrite(LED_BUILTIN, LOW);
|
||||
// Update mock RPM (ramping)
|
||||
rpm_update();
|
||||
|
||||
delay(1000); // 1Hz update rate
|
||||
// Process any commands from Pi
|
||||
if (comms_update()) {
|
||||
const char* cmd = comms_get_command();
|
||||
// Future: handle commands like "PING", "SET_RATE", etc.
|
||||
// For now, echo back as acknowledgment
|
||||
if (cmd[0] != '\0') {
|
||||
comms_send("ACK", cmd);
|
||||
}
|
||||
}
|
||||
|
||||
// Send telemetry at fixed interval
|
||||
unsigned long now = millis();
|
||||
if (now - lastTelemetryTime >= TELEMETRY_INTERVAL_MS) {
|
||||
lastTelemetryTime = now;
|
||||
sendTelemetry();
|
||||
}
|
||||
|
||||
// Heartbeat - quick blink if IMU fresh, slow blink if stale
|
||||
static unsigned long lastBlink = 0;
|
||||
unsigned long blinkInterval = imu_is_fresh() ? 500 : 2000;
|
||||
if (now - lastBlink >= blinkInterval) {
|
||||
lastBlink = now;
|
||||
digitalWrite(LED_BUILTIN, !digitalRead(LED_BUILTIN));
|
||||
}
|
||||
}
|
||||
|
||||
void sendTelemetry() {
|
||||
// Send all telemetry in a single TSV frame
|
||||
float voltage = voltage_read();
|
||||
const ImuData& imu = imu_get_data();
|
||||
bool imu_valid = imu_is_fresh();
|
||||
int rpm = rpm_get();
|
||||
int gear = gear_get(rpm);
|
||||
|
||||
comms_send_telemetry(voltage, imu, imu_valid, rpm, gear);
|
||||
}
|
||||
|
||||
28
arduino/main/rpm.cpp
Normal file
28
arduino/main/rpm.cpp
Normal file
@@ -0,0 +1,28 @@
|
||||
#include "rpm.h"
|
||||
#include <Arduino.h>
|
||||
|
||||
// Mock RPM: ramps up/down between idle and redline
|
||||
static int _rpm = 800;
|
||||
static unsigned long _lastUpdate = 0;
|
||||
static const unsigned long RPM_UPDATE_INTERVAL_MS = 100; // 10Hz ramp rate
|
||||
|
||||
void rpm_init() {
|
||||
_rpm = 800;
|
||||
_lastUpdate = 0;
|
||||
}
|
||||
|
||||
void rpm_update() {
|
||||
unsigned long now = millis();
|
||||
if (now - _lastUpdate < RPM_UPDATE_INTERVAL_MS) {
|
||||
return; // Not time yet
|
||||
}
|
||||
_lastUpdate = now;
|
||||
|
||||
// +10 RPM every 100ms = ~7s to sweep 800-8000
|
||||
_rpm += 10;
|
||||
if (_rpm >= 8000) { _rpm = 800; }
|
||||
}
|
||||
|
||||
int rpm_get() {
|
||||
return _rpm;
|
||||
}
|
||||
8
arduino/main/rpm.h
Normal file
8
arduino/main/rpm.h
Normal file
@@ -0,0 +1,8 @@
|
||||
#ifndef RPM_H
|
||||
#define RPM_H
|
||||
|
||||
void rpm_init();
|
||||
void rpm_update(); // Call in loop
|
||||
int rpm_get(); // Returns current RPM (0 if invalid)
|
||||
|
||||
#endif
|
||||
@@ -10,18 +10,51 @@ static const int PIN_VBAT = A0;
|
||||
static const float DIVIDER_RATIO = 47.0 / (100.0 + 47.0); // ~0.3197
|
||||
static const float ADC_REF = 5.0;
|
||||
static const int ADC_MAX = 1023;
|
||||
static const float OFFSET = 0.2; // calib
|
||||
|
||||
// Sliding window smoother (max 32 samples to keep RAM usage sane)
|
||||
static const int MAX_WINDOW = 32;
|
||||
static int _samples[MAX_WINDOW];
|
||||
static int _windowSize = 20; // Active window size
|
||||
static int _sampleIndex = 0;
|
||||
static long _sampleSum = 0;
|
||||
|
||||
void voltage_init() {
|
||||
// analogRead doesn't need explicit pinMode, but here for future config
|
||||
// e.g., could switch to internal 1.1V reference for different range
|
||||
voltage_set_smoothing(20); // Default 20 samples
|
||||
}
|
||||
|
||||
void voltage_set_smoothing(int windowSize) {
|
||||
// Clamp to valid range
|
||||
if (windowSize < 1) windowSize = 1;
|
||||
if (windowSize > MAX_WINDOW) windowSize = MAX_WINDOW;
|
||||
_windowSize = windowSize;
|
||||
|
||||
// Pre-fill window with current reading
|
||||
int initial = analogRead(PIN_VBAT);
|
||||
for (int i = 0; i < _windowSize; i++) {
|
||||
_samples[i] = initial;
|
||||
}
|
||||
_sampleSum = (long)initial * _windowSize;
|
||||
_sampleIndex = 0;
|
||||
}
|
||||
|
||||
int voltage_read_raw() {
|
||||
return analogRead(PIN_VBAT);
|
||||
}
|
||||
|
||||
float voltage_read() {
|
||||
int raw = voltage_read_raw();
|
||||
float vDivider = (raw / (float)ADC_MAX) * ADC_REF;
|
||||
return vDivider / DIVIDER_RATIO;
|
||||
int voltage_read_smoothed() {
|
||||
int raw = analogRead(PIN_VBAT);
|
||||
|
||||
_sampleSum -= _samples[_sampleIndex]; // Remove oldest
|
||||
_samples[_sampleIndex] = raw; // Store new
|
||||
_sampleSum += raw; // Add new
|
||||
_sampleIndex = (_sampleIndex + 1) % _windowSize;
|
||||
|
||||
return _sampleSum / _windowSize;
|
||||
}
|
||||
|
||||
float voltage_read() {
|
||||
int raw = voltage_read_smoothed();
|
||||
float vDivider = (raw / (float)ADC_MAX) * ADC_REF;
|
||||
return vDivider / DIVIDER_RATIO + OFFSET;
|
||||
}
|
||||
|
||||
@@ -6,10 +6,17 @@
|
||||
// Initialize voltage monitoring (call in setup)
|
||||
void voltage_init();
|
||||
|
||||
// Read battery voltage, returns volts (e.g., 12.5)
|
||||
// Set smoothing window size (1-32 samples, default 20)
|
||||
// Resets the buffer with current reading
|
||||
void voltage_set_smoothing(int windowSize);
|
||||
|
||||
// Read battery voltage (smoothed), returns volts (e.g., 12.5)
|
||||
float voltage_read();
|
||||
|
||||
// Read raw ADC value (0-1023)
|
||||
// Read smoothed ADC value (averaged over window)
|
||||
int voltage_read_smoothed();
|
||||
|
||||
// Read raw ADC value (0-1023), no smoothing
|
||||
int voltage_read_raw();
|
||||
|
||||
#endif
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"dark": {
|
||||
"background": "#404040",
|
||||
"background": "#303030",
|
||||
"foreground": "#EAEAEA",
|
||||
"highlight": "#FA1504",
|
||||
"subdued": "#fda052"
|
||||
},
|
||||
"bright": {
|
||||
"background": "#fda052",
|
||||
"foreground": "#202020",
|
||||
"highlight": "#FB2E0A",
|
||||
"foreground": "#303030",
|
||||
"highlight": "#df2100",
|
||||
"subdued": "#EAEAEA"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,12 @@ Python GPS and Arduino telemetry service for Smart Serow. Connects to `gpsd` and
|
||||
# Install uv if you haven't
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
# Install dependencies
|
||||
# Install system dependencies (GPIO)
|
||||
sudo apt install python3-rpi.gpio
|
||||
|
||||
# Create venv with access to system packages, then sync
|
||||
cd pi/backend
|
||||
uv venv --system-site-packages
|
||||
uv sync
|
||||
```
|
||||
|
||||
@@ -26,6 +30,8 @@ uv run flask --app main run --host 0.0.0.0 --port 5000 --reload
|
||||
|
||||
## API
|
||||
|
||||
### HTTP Endpoints
|
||||
|
||||
| Endpoint | Description |
|
||||
|----------|-------------|
|
||||
| `GET /health` | Health check, shows gpsd and Arduino connection status |
|
||||
@@ -34,6 +40,34 @@ uv run flask --app main run --host 0.0.0.0 --port 5000 --reload
|
||||
| `GET /arduino` | Latest Arduino telemetry (voltage, rpm, eng_temp, gear) |
|
||||
| `GET /arduino/history` | Last 100 buffered Arduino readings |
|
||||
|
||||
### WebSocket Events (socket.io)
|
||||
|
||||
Real-time data is pushed over WebSocket. The UI connects once and receives streams.
|
||||
|
||||
**Server → Client:**
|
||||
|
||||
| Event | Description |
|
||||
|-------|-------------|
|
||||
| `arduino` | Real-time telemetry (voltage, rpm, roll, pitch, accel, etc.) |
|
||||
| `gps` | GPS position updates |
|
||||
| `status` | Connection status + `theme_switch` signal from GPIO |
|
||||
| `alert` | System alerts |
|
||||
| `ack` | Command acknowledgments |
|
||||
|
||||
**Client → Server:**
|
||||
|
||||
| Event | Description |
|
||||
|-------|-------------|
|
||||
| `button` | UI button presses (horn, light, indicators, hazard) |
|
||||
| `emergency` | Emergency signal |
|
||||
|
||||
### Throttling
|
||||
|
||||
WebSocket data is rate-limited to prevent flooding:
|
||||
|
||||
- **Arduino data**: 20Hz max
|
||||
- **GPS data**: 1Hz max
|
||||
|
||||
## Test from SSH
|
||||
|
||||
```bash
|
||||
@@ -106,8 +140,56 @@ arduino = ArduinoService(port="/dev/ttyACM0", baudrate=115200)
|
||||
|
||||
- **GPS**: If `gpsdclient` isn't installed or gpsd isn't running, generates fake GPS data
|
||||
- **Arduino**: If `pyserial` isn't installed or serial port unavailable, generates fake telemetry
|
||||
- **GPIO**: If `RPi.GPIO` isn't available, runs in mock mode (always returns default state)
|
||||
|
||||
Both services run in stub mode for UI testing without hardware.
|
||||
All services run in stub mode for UI testing without hardware.
|
||||
|
||||
## GPIO Setup
|
||||
|
||||
The `gpio_service.py` handles physical switch inputs (e.g., theme toggle on GPIO20).
|
||||
|
||||
### Known Quirks
|
||||
|
||||
**Use apt-installed RPi.GPIO, not pip:**
|
||||
```bash
|
||||
sudo apt install python3-rpi.gpio
|
||||
```
|
||||
|
||||
The pip version (`RPi.GPIO`) requires compilation with `python3-dev` headers. The apt package is pre-compiled and Just Works. The venv must be created with `--system-site-packages` to see it.
|
||||
|
||||
**gpiozero doesn't work (TODO):**
|
||||
|
||||
`gpiozero` is the "modern" GPIO library but has issues in this setup:
|
||||
- Requires a pin factory backend (`lgpio`, `rpigpio`, `pigpio`, or `native`)
|
||||
- `lgpio`/`rpi-lgpio` via pip needs `swig` to compile
|
||||
- `native` backend breaks under gevent monkey-patching (`select.epoll` missing)
|
||||
- May revisit if we need gpiozero-specific features
|
||||
|
||||
**Software pull-up/down conflicts with external resistors:**
|
||||
|
||||
If using an external pull-down resistor (especially high values like 1MΩ), disable the software pull:
|
||||
```python
|
||||
GPIO.setup(pin, GPIO.IN, pull_up_down=GPIO.PUD_OFF)
|
||||
```
|
||||
|
||||
The Pi's internal pull-down (~50kΩ) will overpower high-value external resistors, causing unexpected voltage divider behavior.
|
||||
|
||||
**Debouncing:**
|
||||
|
||||
Physical switches/connectors need debouncing. Current implementation requires 15 consecutive identical readings (~750ms at 20Hz) before accepting a state change. Tune `required_consecutive` in `gpio_service.py` as needed.
|
||||
|
||||
## Utilities
|
||||
|
||||
Standalone tools live in `pi/utils/` (not part of the backend service):
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `at_terminal.py` | Interactive AT command terminal for SIM7600 (pyserial). Default port: `/dev/ttyUSB2` |
|
||||
|
||||
```bash
|
||||
python pi/utils/at_terminal.py # default /dev/ttyUSB2
|
||||
python pi/utils/at_terminal.py /dev/ttyUSB3 # specify port
|
||||
```
|
||||
|
||||
## Deploy
|
||||
|
||||
|
||||
@@ -1,255 +1,308 @@
|
||||
"""Arduino service - connects to Arduino Nano via serial, buffers telemetry."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import Any
|
||||
|
||||
# pyserial for UART communication
|
||||
try:
|
||||
import serial
|
||||
except ImportError:
|
||||
serial = None # Allow import without pyserial for testing structure
|
||||
|
||||
|
||||
class ArduinoService:
|
||||
"""Threaded Arduino serial reader with buffering and auto-reconnect."""
|
||||
|
||||
# Regex patterns for legacy text protocol
|
||||
PATTERNS = {
|
||||
"voltage": re.compile(r"V_bat:\s*(\d+\.?\d*)V?", re.IGNORECASE),
|
||||
"rpm": re.compile(r"RPM:\s*(\d+)", re.IGNORECASE),
|
||||
"eng_temp": re.compile(r"ENG:\s*(\d+)C?", re.IGNORECASE),
|
||||
"gear": re.compile(r"GEAR:\s*(\d+)", re.IGNORECASE),
|
||||
}
|
||||
|
||||
# ACK pattern: "ACK:CMD:STATUS" or "ACK:CMD:STATUS:extra"
|
||||
ACK_PATTERN = re.compile(r"ACK:(\w+):(\w+)(?::(.*))?")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
port: str = "/dev/ttyUSB0",
|
||||
baudrate: int = 115200,
|
||||
buffer_size: int = 100,
|
||||
):
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
self.buffer_size = buffer_size
|
||||
|
||||
self._buffer: deque[dict[str, Any]] = deque(maxlen=buffer_size)
|
||||
self._latest: dict[str, Any] = {}
|
||||
self._connected = False
|
||||
self._running = False
|
||||
self._thread: threading.Thread | None = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# Callbacks for push-based updates
|
||||
self._on_data_callback = None
|
||||
self._on_ack_callback = None
|
||||
|
||||
# Serial port handle for sending commands
|
||||
self._serial: Any = None
|
||||
self._serial_lock = threading.Lock()
|
||||
|
||||
def set_on_data(self, callback):
|
||||
"""Set callback for new telemetry data. Called with data dict."""
|
||||
self._on_data_callback = callback
|
||||
|
||||
def set_on_ack(self, callback):
|
||||
"""Set callback for ACK responses. Called with (cmd, status, extra)."""
|
||||
self._on_ack_callback = callback
|
||||
|
||||
def send_command(self, cmd: str, params: dict | None = None) -> bool:
|
||||
"""Send a command to Arduino via serial.
|
||||
|
||||
Format: "CMD:NAME:PARAM1:PARAM2..." followed by newline
|
||||
|
||||
Args:
|
||||
cmd: Command name (e.g., "HORN", "LIGHT")
|
||||
params: Optional parameters dict
|
||||
|
||||
Returns:
|
||||
True if sent successfully, False if serial unavailable
|
||||
"""
|
||||
with self._serial_lock:
|
||||
if self._serial is None or not self._connected:
|
||||
print(f"[Arduino] Cannot send command, not connected")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Build command string
|
||||
parts = ["CMD", cmd.upper()]
|
||||
if params:
|
||||
for key, val in params.items():
|
||||
parts.append(f"{key}={val}")
|
||||
line = ":".join(parts) + "\n"
|
||||
|
||||
self._serial.write(line.encode("utf-8"))
|
||||
self._serial.flush()
|
||||
print(f"[Arduino] Sent: {line.strip()}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[Arduino] Failed to send command: {e}")
|
||||
return False
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self._connected
|
||||
|
||||
def get_latest(self) -> dict[str, Any]:
|
||||
"""Get most recent telemetry values."""
|
||||
with self._lock:
|
||||
return self._latest.copy() if self._latest else {"error": "no data"}
|
||||
|
||||
def get_buffer(self) -> list[dict[str, Any]]:
|
||||
"""Get buffered telemetry history."""
|
||||
with self._lock:
|
||||
return list(self._buffer)
|
||||
|
||||
def start(self):
|
||||
"""Start background serial reader thread."""
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._reader_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
"""Stop background reader."""
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=2.0)
|
||||
|
||||
def _reader_loop(self):
|
||||
"""Main reader loop with reconnection logic."""
|
||||
while self._running:
|
||||
try:
|
||||
self._connect_and_read()
|
||||
except Exception as e:
|
||||
self._connected = False
|
||||
print(f"[Arduino] Connection error: {e}, retrying in 5s...")
|
||||
time.sleep(5)
|
||||
|
||||
def _connect_and_read(self):
|
||||
"""Connect to Arduino serial and read data."""
|
||||
if serial is None:
|
||||
# Stub mode - no pyserial installed
|
||||
print("[Arduino] pyserial not installed, running in stub mode")
|
||||
self._stub_mode()
|
||||
return
|
||||
|
||||
try:
|
||||
ser = serial.Serial(
|
||||
port=self.port,
|
||||
baudrate=self.baudrate,
|
||||
timeout=1.0,
|
||||
)
|
||||
except serial.SerialException as e:
|
||||
print(f"[Arduino] Cannot open {self.port}: {e}, falling back to stub mode")
|
||||
self._stub_mode()
|
||||
return
|
||||
|
||||
try:
|
||||
# Store serial handle for send_command()
|
||||
with self._serial_lock:
|
||||
self._serial = ser
|
||||
self._connected = True
|
||||
print(f"[Arduino] Connected to {self.port} @ {self.baudrate} baud")
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
line = ser.readline().decode("utf-8", errors="ignore").strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Check for ACK responses first
|
||||
ack_match = self.ACK_PATTERN.match(line)
|
||||
if ack_match:
|
||||
cmd, status, extra = ack_match.groups()
|
||||
if self._on_ack_callback:
|
||||
self._on_ack_callback(cmd, status, extra)
|
||||
continue
|
||||
|
||||
data = self._parse_line(line)
|
||||
if data:
|
||||
data["time"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
with self._lock:
|
||||
# Merge new values into latest (preserve old values for partial updates)
|
||||
for key, val in data.items():
|
||||
if val is not None:
|
||||
self._latest[key] = val
|
||||
self._latest["time"] = data["time"]
|
||||
self._buffer.append(self._latest.copy())
|
||||
|
||||
# Invoke callback with new data
|
||||
if self._on_data_callback:
|
||||
self._on_data_callback(self._latest.copy())
|
||||
|
||||
except serial.SerialException as e:
|
||||
print(f"[Arduino] Serial error: {e}")
|
||||
break
|
||||
|
||||
finally:
|
||||
self._connected = False
|
||||
with self._serial_lock:
|
||||
self._serial = None
|
||||
ser.close()
|
||||
|
||||
def _parse_line(self, line: str) -> dict[str, Any] | None:
|
||||
"""Parse a line from Arduino - JSON first, fallback to regex.
|
||||
|
||||
JSON format: {"v":12.45,"rpm":4500,"eng":85,"gear":3}
|
||||
Legacy text: V_bat: 12.45V
|
||||
"""
|
||||
# Try JSON first (production format)
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
return {
|
||||
"voltage": obj.get("v"),
|
||||
"rpm": obj.get("rpm"),
|
||||
"eng_temp": obj.get("eng"),
|
||||
"gear": obj.get("gear"),
|
||||
}
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Fallback to regex for legacy text protocol
|
||||
result = {}
|
||||
for key, pattern in self.PATTERNS.items():
|
||||
match = pattern.search(line)
|
||||
if match:
|
||||
val = match.group(1)
|
||||
result[key] = float(val) if "." in val else int(val)
|
||||
|
||||
return result if result else None
|
||||
|
||||
def _stub_mode(self):
|
||||
"""Fake data for testing without Arduino connected."""
|
||||
import random
|
||||
|
||||
_rpm = 3000
|
||||
|
||||
while self._running:
|
||||
self._connected = True
|
||||
data = {
|
||||
"time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"voltage": round(12.0 + random.uniform(-0.5, 0.8), 2),
|
||||
"rpm": _rpm if random.random() > 0.1 else None,
|
||||
"eng_temp": random.randint(60, 95),
|
||||
"gear": random.randint(1, 6) if random.random() > 0.2 else 0, # 0 = neutral
|
||||
}
|
||||
_rpm += 10
|
||||
if _rpm > 7500:
|
||||
_rpm = 500
|
||||
|
||||
with self._lock:
|
||||
self._latest = data
|
||||
self._buffer.append(data)
|
||||
|
||||
# Invoke callback with new data
|
||||
if self._on_data_callback:
|
||||
self._on_data_callback(data)
|
||||
|
||||
time.sleep(0.5) # 2Hz stub updates
|
||||
"""Arduino service - connects to Arduino Nano via serial, buffers telemetry."""
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import Any
|
||||
|
||||
# pyserial for UART communication
|
||||
try:
|
||||
import serial
|
||||
except ImportError:
|
||||
serial = None # Allow import without pyserial for testing structure
|
||||
|
||||
|
||||
class ArduinoService:
|
||||
"""Threaded Arduino serial reader with buffering and auto-reconnect."""
|
||||
|
||||
# TSV field names (order per PROTOCOL.md)
|
||||
TSV_FIELDS = ['voltage', 'ax', 'ay', 'az', 'gx', 'gy', 'gz', 'roll', 'pitch', 'yaw', 'rpm', 'gear']
|
||||
|
||||
# Regex patterns for legacy text protocol (backwards compatibility)
|
||||
PATTERNS = {
|
||||
"voltage": re.compile(r"V_bat:\s*(\d+\.?\d*)V?", re.IGNORECASE),
|
||||
"rpm": re.compile(r"RPM:\s*(\d+)", re.IGNORECASE),
|
||||
"eng_temp": re.compile(r"ENG:\s*(\d+)C?", re.IGNORECASE),
|
||||
"gear": re.compile(r"GEAR:\s*(\d+)", re.IGNORECASE),
|
||||
}
|
||||
|
||||
# ACK pattern: "ACK:CMD:STATUS" or "ACK:CMD:STATUS:extra"
|
||||
ACK_PATTERN = re.compile(r"ACK:(\w+):(\w+)(?::(.*))?")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
port: str = "/dev/serial0",
|
||||
baudrate: int = 115200,
|
||||
buffer_size: int = 100,
|
||||
):
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
self.buffer_size = buffer_size
|
||||
|
||||
self._buffer: deque[dict[str, Any]] = deque(maxlen=buffer_size)
|
||||
self._latest: dict[str, Any] = {}
|
||||
self._connected = False
|
||||
self._running = False
|
||||
self._thread: threading.Thread | None = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# Callbacks for push-based updates
|
||||
self._on_data_callback = None
|
||||
self._on_ack_callback = None
|
||||
|
||||
# Serial port handle for sending commands
|
||||
self._serial: Any = None
|
||||
self._serial_lock = threading.Lock()
|
||||
|
||||
# Periodic status logging
|
||||
self._last_status_log = 0.0
|
||||
self._frame_count = 0
|
||||
|
||||
def set_on_data(self, callback):
|
||||
"""Set callback for new telemetry data. Called with data dict."""
|
||||
self._on_data_callback = callback
|
||||
|
||||
def set_on_ack(self, callback):
|
||||
"""Set callback for ACK responses. Called with (cmd, status, extra)."""
|
||||
self._on_ack_callback = callback
|
||||
|
||||
def send_command(self, cmd: str, params: dict | None = None) -> bool:
|
||||
"""Send a command to Arduino via serial.
|
||||
|
||||
Format: "CMD:NAME:PARAM1:PARAM2..." followed by newline
|
||||
|
||||
Args:
|
||||
cmd: Command name (e.g., "HORN", "LIGHT")
|
||||
params: Optional parameters dict
|
||||
|
||||
Returns:
|
||||
True if sent successfully, False if serial unavailable
|
||||
"""
|
||||
with self._serial_lock:
|
||||
if self._serial is None or not self._connected:
|
||||
print(f"[Arduino] Cannot send command, not connected")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Build command string
|
||||
parts = ["CMD", cmd.upper()]
|
||||
if params:
|
||||
for key, val in params.items():
|
||||
parts.append(f"{key}={val}")
|
||||
line = ":".join(parts) + "\n"
|
||||
|
||||
self._serial.write(line.encode("utf-8"))
|
||||
self._serial.flush()
|
||||
print(f"[Arduino] Sent: {line.strip()}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[Arduino] Failed to send command: {e}")
|
||||
return False
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self._connected
|
||||
|
||||
def get_latest(self) -> dict[str, Any]:
|
||||
"""Get most recent telemetry values."""
|
||||
with self._lock:
|
||||
return self._latest.copy() if self._latest else {"error": "no data"}
|
||||
|
||||
def get_buffer(self) -> list[dict[str, Any]]:
|
||||
"""Get buffered telemetry history."""
|
||||
with self._lock:
|
||||
return list(self._buffer)
|
||||
|
||||
def start(self):
|
||||
"""Start background serial reader thread."""
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._reader_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
"""Stop background reader."""
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=2.0)
|
||||
|
||||
def _reader_loop(self):
|
||||
"""Main reader loop with reconnection logic."""
|
||||
while self._running:
|
||||
try:
|
||||
self._connect_and_read()
|
||||
except Exception as e:
|
||||
self._connected = False
|
||||
print(f"[Arduino] Connection error: {e}, retrying in 5s...")
|
||||
time.sleep(5)
|
||||
|
||||
def _connect_and_read(self):
|
||||
"""Connect to Arduino serial and read data."""
|
||||
if serial is None:
|
||||
print("[Arduino] pyserial not installed, cannot connect")
|
||||
return # Will retry via _reader_loop after 5s
|
||||
|
||||
try:
|
||||
ser = serial.Serial(
|
||||
port=self.port,
|
||||
baudrate=self.baudrate,
|
||||
timeout=1.0,
|
||||
)
|
||||
except serial.SerialException as e:
|
||||
print(f"[Arduino] Cannot open {self.port}: {e}")
|
||||
return # Will retry via _reader_loop after 5s
|
||||
|
||||
try:
|
||||
# Store serial handle for send_command()
|
||||
with self._serial_lock:
|
||||
self._serial = ser
|
||||
self._connected = True
|
||||
self._last_status_log = time.time()
|
||||
self._frame_count = 0
|
||||
print(f"[Arduino] Connected to {self.port} @ {self.baudrate} baud")
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
# Read null-terminated line (TSV protocol)
|
||||
line = self._read_null_terminated(ser)
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Check for ACK responses first (legacy newline-terminated)
|
||||
ack_match = self.ACK_PATTERN.match(line)
|
||||
if ack_match:
|
||||
cmd, status, extra = ack_match.groups()
|
||||
if self._on_ack_callback:
|
||||
self._on_ack_callback(cmd, status, extra)
|
||||
continue
|
||||
|
||||
data = self._parse_line(line)
|
||||
if data:
|
||||
data["time"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
with self._lock:
|
||||
# Merge new values into latest (preserve old values for partial updates)
|
||||
for key, val in data.items():
|
||||
if val is not None and not (isinstance(val, float) and math.isnan(val)):
|
||||
self._latest[key] = val
|
||||
self._latest["time"] = data["time"]
|
||||
self._buffer.append(self._latest.copy())
|
||||
|
||||
# Invoke callback with new data
|
||||
if self._on_data_callback:
|
||||
self._on_data_callback(self._latest.copy())
|
||||
|
||||
# Periodic status log (every 5s)
|
||||
self._frame_count += 1
|
||||
now = time.time()
|
||||
if now - self._last_status_log >= 5.0:
|
||||
elapsed = now - self._last_status_log
|
||||
fps = self._frame_count / elapsed
|
||||
v = self._latest.get('voltage', 0)
|
||||
rpm = self._latest.get('rpm', 0)
|
||||
gear = self._latest.get('gear', 0)
|
||||
roll = self._latest.get('roll', 0)
|
||||
print(f"[Arduino] {fps:.1f} fps | V={v:.1f} RPM={int(rpm)} G={int(gear)} roll={roll:.1f}°")
|
||||
self._last_status_log = now
|
||||
self._frame_count = 0
|
||||
|
||||
except serial.SerialException as e:
|
||||
print(f"[Arduino] Serial error: {e}")
|
||||
break
|
||||
|
||||
finally:
|
||||
self._connected = False
|
||||
with self._serial_lock:
|
||||
self._serial = None
|
||||
ser.close()
|
||||
|
||||
def _read_null_terminated(self, ser) -> str:
|
||||
"""Read bytes until null terminator or newline (fallback for legacy)."""
|
||||
buf = bytearray()
|
||||
while self._running:
|
||||
byte = ser.read(1)
|
||||
if not byte:
|
||||
# Timeout
|
||||
if buf:
|
||||
# Return partial buffer if we have data
|
||||
return buf.decode("utf-8", errors="ignore").strip()
|
||||
return ""
|
||||
if byte == b'\x00' or byte == b'\n' or byte == b'\r':
|
||||
# End of frame
|
||||
if buf:
|
||||
return buf.decode("utf-8", errors="ignore").strip()
|
||||
# Skip empty lines / consecutive terminators
|
||||
continue
|
||||
buf.append(byte[0])
|
||||
# Safety limit
|
||||
if len(buf) > 256:
|
||||
return buf.decode("utf-8", errors="ignore").strip()
|
||||
|
||||
def _parse_line(self, line: str) -> dict[str, Any] | None:
|
||||
"""Parse a line from Arduino - TSV first, then JSON, fallback to regex.
|
||||
|
||||
TSV format: 12.45\t0.02\t-0.01\t... (10 fields, per PROTOCOL.md)
|
||||
JSON format: {"v":12.45,"rpm":4500,"eng":85,"gear":3}
|
||||
Legacy text: V_bat: 12.45V
|
||||
"""
|
||||
# Try TSV first (new protocol)
|
||||
if '\t' in line:
|
||||
return self._parse_tsv(line)
|
||||
|
||||
# Try JSON (may still be used for special messages)
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
return {
|
||||
"voltage": obj.get("v"),
|
||||
"rpm": obj.get("rpm"),
|
||||
"eng_temp": obj.get("eng"),
|
||||
"gear": obj.get("gear"),
|
||||
}
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Fallback to regex for legacy text protocol
|
||||
result = {}
|
||||
for key, pattern in self.PATTERNS.items():
|
||||
match = pattern.search(line)
|
||||
if match:
|
||||
val = match.group(1)
|
||||
result[key] = float(val) if "." in val else int(val)
|
||||
|
||||
return result if result else None
|
||||
|
||||
def _parse_tsv(self, line: str) -> dict[str, Any] | None:
|
||||
"""Parse TSV telemetry frame per PROTOCOL.md.
|
||||
|
||||
Fields: voltage, ax, ay, az, gx, gy, gz, roll, pitch, yaw
|
||||
Empty fields (stale IMU) become NaN.
|
||||
"""
|
||||
fields = line.split('\t')
|
||||
if len(fields) != len(self.TSV_FIELDS):
|
||||
# Wrong field count - might be debug output or malformed
|
||||
return None
|
||||
|
||||
result = {}
|
||||
for i, name in enumerate(self.TSV_FIELDS):
|
||||
val_str = fields[i].strip()
|
||||
if val_str == '':
|
||||
# Empty field = stale/missing data
|
||||
result[name] = float('nan')
|
||||
else:
|
||||
try:
|
||||
result[name] = float(val_str)
|
||||
except ValueError:
|
||||
result[name] = float('nan')
|
||||
|
||||
# IMU axis correction for mounting orientation
|
||||
# Pitch/yaw inverted for motorcycle frame alignment (roll left as-is)
|
||||
if 'pitch' in result and not math.isnan(result['pitch']):
|
||||
result['pitch'] = -result['pitch']
|
||||
if 'yaw' in result and not math.isnan(result['yaw']):
|
||||
result['yaw'] = -result['yaw']
|
||||
|
||||
return result
|
||||
|
||||
|
||||
141
pi/backend/gpio_service.py
Normal file
141
pi/backend/gpio_service.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""GPIO service for Pi Zero - edge-triggered monitoring.
|
||||
|
||||
Polls GPIO pins and exposes state changes for inclusion in other payloads.
|
||||
Keeps UART separate (handled by arduino_service).
|
||||
|
||||
Tries RPi.GPIO first (apt install python3-rpi.gpio), falls back to gpiozero.
|
||||
"""
|
||||
|
||||
import gevent
|
||||
|
||||
# Try RPi.GPIO first (commonly installed via apt on Pi)
|
||||
_BACKEND = None
|
||||
try:
|
||||
import RPi.GPIO as GPIO
|
||||
_BACKEND = "rpigpio"
|
||||
print("[GPIO] Using RPi.GPIO backend")
|
||||
except ImportError:
|
||||
try:
|
||||
from gpiozero import Button
|
||||
_BACKEND = "gpiozero"
|
||||
print("[GPIO] Using gpiozero backend")
|
||||
except ImportError:
|
||||
print("[GPIO] No GPIO library available - running in mock mode")
|
||||
|
||||
|
||||
# Pin assignments
|
||||
PIN_THEME_SWITCH = 20 # Physical switch for light/dark theme
|
||||
|
||||
|
||||
class GPIOService:
|
||||
"""Monitors GPIO pins and tracks state changes."""
|
||||
|
||||
def __init__(self):
|
||||
self._running = False
|
||||
self._greenlet = None
|
||||
self._theme_button = None # gpiozero only
|
||||
self._gpio_working = False
|
||||
|
||||
# Theme switch state
|
||||
self._theme_switch_state = False # False = light, True = dark
|
||||
self._pending_state = None # Candidate new state
|
||||
self._pending_count = 0 # Consecutive readings of pending state
|
||||
|
||||
if _BACKEND == "rpigpio":
|
||||
try:
|
||||
GPIO.setmode(GPIO.BCM)
|
||||
GPIO.setwarnings(False)
|
||||
# No software pull - using external hardware pull-down
|
||||
GPIO.setup(PIN_THEME_SWITCH, GPIO.IN, pull_up_down=GPIO.PUD_OFF)
|
||||
self._gpio_working = True
|
||||
except Exception as e:
|
||||
print(f"[GPIO] RPi.GPIO init failed: {e}")
|
||||
elif _BACKEND == "gpiozero":
|
||||
try:
|
||||
self._theme_button = Button(PIN_THEME_SWITCH, pull_up=False)
|
||||
self._gpio_working = True
|
||||
except Exception as e:
|
||||
print(f"[GPIO] gpiozero init failed: {e}")
|
||||
|
||||
def _read_pin(self):
|
||||
"""Read current pin state. Returns True if HIGH (dark mode)."""
|
||||
if _BACKEND == "rpigpio":
|
||||
return GPIO.input(PIN_THEME_SWITCH) == GPIO.HIGH
|
||||
elif _BACKEND == "gpiozero" and self._theme_button:
|
||||
return self._theme_button.is_pressed
|
||||
return self._theme_switch_state # Mock: return current
|
||||
|
||||
def start(self):
|
||||
"""Start background polling."""
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
|
||||
# Read initial state
|
||||
if self._gpio_working:
|
||||
self._theme_switch_state = self._read_pin()
|
||||
else:
|
||||
self._theme_switch_state = True # Mock: default dark
|
||||
|
||||
self._greenlet = gevent.spawn(self._poll_loop)
|
||||
print(f"[GPIO] Started, theme_switch initial={self._theme_switch_state}")
|
||||
|
||||
def stop(self):
|
||||
"""Stop background polling."""
|
||||
self._running = False
|
||||
if self._greenlet:
|
||||
self._greenlet.kill()
|
||||
self._greenlet = None
|
||||
if _BACKEND == "rpigpio":
|
||||
try:
|
||||
GPIO.cleanup([PIN_THEME_SWITCH])
|
||||
except Exception:
|
||||
pass
|
||||
elif self._theme_button:
|
||||
self._theme_button.close()
|
||||
self._theme_button = None
|
||||
|
||||
def _poll_loop(self):
|
||||
"""Poll GPIO at ~20Hz, update state with consecutive-read debounce."""
|
||||
poll_count = 0
|
||||
# Require N consecutive same readings to accept state change
|
||||
# At 20Hz: 10 readings = 500ms, 20 readings = 1s
|
||||
required_consecutive = 11 # ~550ms of stable signal
|
||||
|
||||
while self._running:
|
||||
gevent.sleep(0.05) # 20Hz
|
||||
poll_count += 1
|
||||
|
||||
if self._gpio_working:
|
||||
current = self._read_pin()
|
||||
|
||||
if current != self._theme_switch_state:
|
||||
# Different from accepted state - count towards change
|
||||
if current == self._pending_state:
|
||||
self._pending_count += 1
|
||||
else:
|
||||
# New candidate state
|
||||
self._pending_state = current
|
||||
self._pending_count = 1
|
||||
|
||||
# Accept change after enough consecutive readings
|
||||
if self._pending_count >= required_consecutive:
|
||||
self._theme_switch_state = current
|
||||
self._pending_state = None
|
||||
self._pending_count = 0
|
||||
print(f"[GPIO] Theme switch: {current} (dark={current})")
|
||||
else:
|
||||
# Matches current state - reset any pending change
|
||||
self._pending_state = None
|
||||
self._pending_count = 0
|
||||
|
||||
# Heartbeat log every ~5 seconds (100 polls at 20Hz)
|
||||
if poll_count >= 100:
|
||||
poll_count = 0
|
||||
raw = 1 if self._theme_switch_state else 0
|
||||
print(f"[GPIO] Pin {PIN_THEME_SWITCH}: {raw} (dark={self._theme_switch_state})")
|
||||
|
||||
@property
|
||||
def theme_switch(self):
|
||||
"""Current theme switch state (True = dark, False = light)."""
|
||||
return self._theme_switch_state
|
||||
@@ -1,143 +1,315 @@
|
||||
"""GPS service - connects to gpsd, buffers data, handles reconnection."""
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import Any
|
||||
|
||||
# gpsdclient is a modern, simple gpsd client
|
||||
# Install gpsd on Pi: sudo apt install gpsd gpsd-clients
|
||||
# Configure: sudo nano /etc/default/gpsd (set DEVICES="/dev/ttyUSB0" or similar)
|
||||
try:
|
||||
from gpsdclient import GPSDClient
|
||||
except ImportError:
|
||||
GPSDClient = None # Allow import without gpsd for testing structure
|
||||
|
||||
|
||||
class GPSService:
|
||||
"""Threaded GPS reader with buffering and auto-reconnect."""
|
||||
|
||||
def __init__(self, host: str = "127.0.0.1", port: int = 2947, buffer_size: int = 100):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.buffer_size = buffer_size
|
||||
|
||||
self._buffer: deque[dict[str, Any]] = deque(maxlen=buffer_size)
|
||||
self._latest: dict[str, Any] = {}
|
||||
self._connected = False
|
||||
self._running = False
|
||||
self._thread: threading.Thread | None = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# Callback for push-based updates
|
||||
self._on_data_callback = None
|
||||
|
||||
def set_on_data(self, callback):
|
||||
"""Set callback for new GPS fix. Called with fix dict."""
|
||||
self._on_data_callback = callback
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self._connected
|
||||
|
||||
def get_latest(self) -> dict[str, Any]:
|
||||
"""Get most recent GPS fix."""
|
||||
with self._lock:
|
||||
return self._latest.copy() if self._latest else {"error": "no data"}
|
||||
|
||||
def get_buffer(self) -> list[dict[str, Any]]:
|
||||
"""Get buffered GPS history."""
|
||||
with self._lock:
|
||||
return list(self._buffer)
|
||||
|
||||
def start(self):
|
||||
"""Start background GPS reader thread."""
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._reader_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
"""Stop background reader."""
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=2.0)
|
||||
|
||||
def _reader_loop(self):
|
||||
"""Main reader loop with reconnection logic."""
|
||||
while self._running:
|
||||
try:
|
||||
self._connect_and_read()
|
||||
except Exception as e:
|
||||
self._connected = False
|
||||
print(f"[GPS] Connection error: {e}, retrying in 5s...")
|
||||
time.sleep(5)
|
||||
|
||||
def _connect_and_read(self):
|
||||
"""Connect to gpsd and read data."""
|
||||
if GPSDClient is None:
|
||||
# Stub mode - no gpsd client installed
|
||||
print("[GPS] gpsdclient not installed, running in stub mode")
|
||||
self._stub_mode()
|
||||
return
|
||||
|
||||
try:
|
||||
client = GPSDClient(host=self.host, port=self.port)
|
||||
except Exception as e:
|
||||
print(f"[GPS] Cannot connect to gpsd at {self.host}:{self.port}: {e}, falling back to stub mode")
|
||||
self._stub_mode()
|
||||
return
|
||||
|
||||
with client:
|
||||
self._connected = True
|
||||
print(f"[GPS] Connected to gpsd at {self.host}:{self.port}")
|
||||
|
||||
for result in client.dict_stream(filter=["TPV"]):
|
||||
if not self._running:
|
||||
break
|
||||
|
||||
# TPV = Time-Position-Velocity report
|
||||
fix = {
|
||||
"time": result.get("time"),
|
||||
"lat": result.get("lat"),
|
||||
"lon": result.get("lon"),
|
||||
"alt": result.get("alt"),
|
||||
"speed": result.get("speed"), # m/s
|
||||
"track": result.get("track"), # heading in degrees
|
||||
"mode": result.get("mode"), # 0=no fix, 2=2D, 3=3D
|
||||
}
|
||||
|
||||
with self._lock:
|
||||
self._latest = fix
|
||||
if fix.get("lat") is not None:
|
||||
self._buffer.append(fix)
|
||||
|
||||
# Invoke callback with new fix
|
||||
if self._on_data_callback:
|
||||
self._on_data_callback(fix)
|
||||
|
||||
def _stub_mode(self):
|
||||
"""Fake data for testing without gpsd."""
|
||||
import random
|
||||
|
||||
while self._running:
|
||||
self._connected = True
|
||||
fix = {
|
||||
"time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"lat": 35.6762 + random.uniform(-0.001, 0.001),
|
||||
"lon": 139.6503 + random.uniform(-0.001, 0.001),
|
||||
"alt": 40.0 + random.uniform(-5, 5),
|
||||
"speed": random.uniform(0, 30),
|
||||
"track": random.uniform(0, 360),
|
||||
"mode": 3,
|
||||
}
|
||||
with self._lock:
|
||||
self._latest = fix
|
||||
self._buffer.append(fix)
|
||||
|
||||
# Invoke callback with new fix
|
||||
if self._on_data_callback:
|
||||
self._on_data_callback(fix)
|
||||
|
||||
time.sleep(1)
|
||||
"""GPS service - connects to gpsd, buffers data, handles reconnection."""
|
||||
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import Any
|
||||
|
||||
# ============================================================================
|
||||
# DEBUG MODE - Set True for development without GPS hardware
|
||||
# When True: skips gpsd entirely, generates realistic mock data
|
||||
# When False: connects to real gpsd (requires GPS device)
|
||||
# ============================================================================
|
||||
_GPS_DEBUG = False
|
||||
|
||||
# gpsdclient is a modern, simple gpsd client
|
||||
# Install gpsd on Pi: sudo apt install gpsd gpsd-clients
|
||||
# Configure: sudo nano /etc/default/gpsd (set DEVICES="/dev/ttyUSB0" or similar)
|
||||
try:
|
||||
from gpsdclient import GPSDClient
|
||||
except ImportError:
|
||||
GPSDClient = None # Allow import without gpsd for testing structure
|
||||
|
||||
|
||||
class GPSService:
|
||||
"""Threaded GPS reader with buffering and auto-reconnect."""
|
||||
|
||||
def __init__(self, host: str = "127.0.0.1", port: int = 2947, buffer_size: int = 100):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.buffer_size = buffer_size
|
||||
|
||||
self._buffer: deque[dict[str, Any]] = deque(maxlen=buffer_size)
|
||||
self._latest: dict[str, Any] = {}
|
||||
self._connected = False
|
||||
self._running = False
|
||||
self._thread: threading.Thread | None = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# Callback for push-based updates
|
||||
self._on_data_callback = None
|
||||
|
||||
# GPS state tracking (NMEA can't distinguish "acquiring" from "lost")
|
||||
self._has_ever_fixed = False # True after first valid fix this session
|
||||
|
||||
# Periodic status logging
|
||||
self._last_status_log = 0.0
|
||||
self._last_state_emit = 0.0
|
||||
self._fix_count = 0
|
||||
|
||||
def set_on_data(self, callback):
|
||||
"""Set callback for new GPS fix. Called with fix dict."""
|
||||
self._on_data_callback = callback
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self._connected
|
||||
|
||||
def get_latest(self) -> dict[str, Any]:
|
||||
"""Get most recent GPS fix."""
|
||||
with self._lock:
|
||||
return self._latest.copy() if self._latest else {"error": "no data"}
|
||||
|
||||
def _gps_state(self, fix: dict) -> str:
|
||||
"""Determine GPS state: acquiring, fix, or lost.
|
||||
|
||||
NMEA doesn't distinguish 'never had fix' from 'lost signal' — both
|
||||
report mode 1 with no position. We track it ourselves.
|
||||
"""
|
||||
has_fix = fix.get("mode") in (2, 3) and fix.get("lat") is not None
|
||||
if has_fix:
|
||||
return "fix"
|
||||
return "lost" if self._has_ever_fixed else "acquiring"
|
||||
|
||||
def get_buffer(self) -> list[dict[str, Any]]:
|
||||
"""Get buffered GPS history."""
|
||||
with self._lock:
|
||||
return list(self._buffer)
|
||||
|
||||
def start(self):
|
||||
"""Start background GPS reader thread."""
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._reader_loop, daemon=True)
|
||||
self._thread.start()
|
||||
print("[GPS] Service started")
|
||||
|
||||
def stop(self):
|
||||
"""Stop background reader."""
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=2.0)
|
||||
|
||||
def _reader_loop(self):
|
||||
"""Main reader loop with reconnection logic."""
|
||||
print("[GPS] Reader thread running")
|
||||
while self._running:
|
||||
try:
|
||||
self._connect_and_read()
|
||||
except Exception as e:
|
||||
self._connected = False
|
||||
print(f"[GPS] Connection error: {e}, retrying in 5s...")
|
||||
time.sleep(5)
|
||||
|
||||
def _connect_and_read(self):
|
||||
"""Connect to gpsd and read data."""
|
||||
# Debug mode: skip gpsd entirely, use stub data
|
||||
if _GPS_DEBUG:
|
||||
print("[GPS] Debug mode enabled, using stub data")
|
||||
self._stub_mode()
|
||||
return
|
||||
|
||||
if GPSDClient is None:
|
||||
print("[GPS] gpsdclient not installed, running in stub mode")
|
||||
self._stub_mode()
|
||||
return
|
||||
|
||||
# Quick check if gpsd is reachable before attempting connection
|
||||
import socket
|
||||
try:
|
||||
sock = socket.create_connection((self.host, self.port), timeout=2.0)
|
||||
sock.close()
|
||||
except (socket.timeout, socket.error, OSError) as e:
|
||||
print(f"[GPS] gpsd not reachable at {self.host}:{self.port}: {e}")
|
||||
raise ConnectionError(f"gpsd not reachable: {e}")
|
||||
|
||||
try:
|
||||
client = GPSDClient(host=self.host, port=self.port)
|
||||
except Exception as e:
|
||||
print(f"[GPS] Cannot connect to gpsd at {self.host}:{self.port}: {e}")
|
||||
raise ConnectionError(f"gpsd connection failed: {e}")
|
||||
|
||||
with client:
|
||||
self._connected = True
|
||||
print(f"[GPS] Connected to gpsd at {self.host}:{self.port}")
|
||||
|
||||
self._last_status_log = time.time()
|
||||
self._fix_count = 0
|
||||
# 120s for initial cold fix, 10s for signal loss after first fix
|
||||
fix_timeout = time.time() + 120.0
|
||||
|
||||
for result in client.dict_stream(filter=["TPV"]):
|
||||
if not self._running:
|
||||
break
|
||||
|
||||
# TPV = Time-Position-Velocity report
|
||||
fix = {
|
||||
"time": result.get("time"),
|
||||
"lat": result.get("lat"),
|
||||
"lon": result.get("lon"),
|
||||
"alt": result.get("alt"),
|
||||
"speed": result.get("speed"), # m/s
|
||||
"track": result.get("track"), # heading in degrees
|
||||
"mode": result.get("mode"), # 0=no fix, 2=2D, 3=3D
|
||||
"satellites": result.get("satellites"), # from SKY messages
|
||||
}
|
||||
|
||||
# Compute state and attach to fix
|
||||
fix["gps_state"] = self._gps_state(fix)
|
||||
|
||||
# Check if this is a real fix (has position) or just empty TPV
|
||||
if fix.get("lat") is None and fix.get("mode") in (None, 0, 1):
|
||||
# No real data yet, check timeout
|
||||
if time.time() > fix_timeout:
|
||||
timeout_s = "120s" if not self._has_ever_fixed else "10s"
|
||||
print(f"[GPS] No GPS fix after {timeout_s}, will retry connection")
|
||||
raise ConnectionError("No GPS fix within timeout")
|
||||
# Emit state periodically so UI knows we're alive
|
||||
now = time.time()
|
||||
if now - self._last_state_emit >= 5.0:
|
||||
self._last_state_emit = now
|
||||
with self._lock:
|
||||
self._latest = fix
|
||||
if self._on_data_callback:
|
||||
self._on_data_callback(fix)
|
||||
continue # Skip empty fixes
|
||||
|
||||
# Got real data — mark first fix, reset timeout to shorter window
|
||||
if not self._has_ever_fixed:
|
||||
self._has_ever_fixed = True
|
||||
self._last_state_emit = 0.0 # Force immediate emit on transition
|
||||
print("[GPS] First fix acquired")
|
||||
fix_timeout = time.time() + 10.0 # 10s timeout for signal loss
|
||||
|
||||
with self._lock:
|
||||
self._latest = fix
|
||||
if fix.get("lat") is not None:
|
||||
self._buffer.append(fix)
|
||||
|
||||
# Invoke callback with new fix
|
||||
if self._on_data_callback:
|
||||
self._on_data_callback(fix)
|
||||
|
||||
# Periodic status log (every 5s)
|
||||
self._fix_count += 1
|
||||
now = time.time()
|
||||
if now - self._last_status_log >= 5.0:
|
||||
elapsed = now - self._last_status_log
|
||||
fps = self._fix_count / elapsed
|
||||
speed = fix.get('speed', 0) or 0
|
||||
track = fix.get('track', 0) or 0
|
||||
mode = fix.get('mode', 0) or 0
|
||||
sats = fix.get('satellites', '?')
|
||||
print(f"[GPS] {fps:.1f} fix/s | {speed:.1f}m/s hdg={track:.0f}° mode={mode} sats={sats}")
|
||||
self._last_status_log = now
|
||||
self._fix_count = 0
|
||||
|
||||
def _stub_mode(self):
|
||||
"""Generate realistic mock GPS data for development/testing.
|
||||
|
||||
Simulates:
|
||||
- Initial acquisition delay (~3s before first fix)
|
||||
- Normal 3D fix with satellites
|
||||
- Occasional signal loss (~30% chance per second, lasts ~2s)
|
||||
- Wandering position near Tokyo
|
||||
"""
|
||||
self._last_status_log = time.time()
|
||||
self._fix_count = 0
|
||||
|
||||
# Signal loss state
|
||||
signal_lost = False
|
||||
signal_lost_until = 0.0
|
||||
|
||||
# Simulate cold start acquisition (~30s)
|
||||
acquiring_until = time.time() + 30.0
|
||||
|
||||
# Base position (Tokyo area)
|
||||
base_lat = 35.6762
|
||||
base_lon = 139.6503
|
||||
base_alt = 40.0
|
||||
|
||||
# Smoothly varying heading/speed
|
||||
heading = random.uniform(0, 360)
|
||||
speed = random.uniform(5, 15)
|
||||
|
||||
while self._running:
|
||||
self._connected = True
|
||||
now = time.time()
|
||||
|
||||
# Simulate initial acquisition period
|
||||
if now < acquiring_until:
|
||||
signal_lost = True # No fix yet
|
||||
elif signal_lost and now >= signal_lost_until:
|
||||
signal_lost = False
|
||||
if self._has_ever_fixed:
|
||||
print("[GPS] Signal recovered (stub)")
|
||||
else:
|
||||
print("[GPS] First fix acquired (stub)")
|
||||
elif not signal_lost:
|
||||
# ~30% chance per second to lose signal
|
||||
if random.random() < 0.3:
|
||||
signal_lost = True
|
||||
signal_lost_until = now + 2 # fixed 2s loss
|
||||
print("[GPS] Signal loss simulation (stub)")
|
||||
|
||||
if signal_lost:
|
||||
# No fix - mode 1, no satellites, no track
|
||||
# Note: use None, not float('nan') - NaN doesn't serialize to valid JSON
|
||||
fix = {
|
||||
"time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"lat": None,
|
||||
"lon": None,
|
||||
"alt": None,
|
||||
"speed": None,
|
||||
"track": None,
|
||||
"mode": 1,
|
||||
"satellites": 0,
|
||||
}
|
||||
else:
|
||||
# Smoothly vary heading and speed
|
||||
heading = (heading + random.uniform(1, 3)) % 360
|
||||
speed = max(0, min(30, speed + random.uniform(-2, 2)))
|
||||
|
||||
if not self._has_ever_fixed:
|
||||
self._has_ever_fixed = True
|
||||
|
||||
fix = {
|
||||
"time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"lat": base_lat + random.uniform(-0.001, 0.001),
|
||||
"lon": base_lon + random.uniform(-0.001, 0.001),
|
||||
"alt": base_alt + random.uniform(-5, 5),
|
||||
"speed": speed,
|
||||
"track": heading,
|
||||
"mode": 3,
|
||||
"satellites": random.randint(6, 12),
|
||||
}
|
||||
|
||||
# Attach state — same logic as real GPS path
|
||||
fix["gps_state"] = self._gps_state(fix)
|
||||
|
||||
with self._lock:
|
||||
self._latest = fix
|
||||
if fix.get("lat") is not None:
|
||||
self._buffer.append(fix)
|
||||
|
||||
# Invoke callback with new fix
|
||||
if self._on_data_callback:
|
||||
self._on_data_callback(fix)
|
||||
|
||||
# Periodic status log (every 5s)
|
||||
self._fix_count += 1
|
||||
if now - self._last_status_log >= 5.0:
|
||||
elapsed = now - self._last_status_log
|
||||
fps = self._fix_count / elapsed
|
||||
speed_val = fix.get('speed') or 0
|
||||
track_val = fix.get('track')
|
||||
track_str = f"{track_val:.0f}" if track_val is not None else "---"
|
||||
mode = fix.get('mode', 0)
|
||||
sats = fix.get('satellites', 0)
|
||||
print(f"[GPS] {fps:.1f} fix/s | {speed_val:.1f}m/s hdg={track_str} mode={mode} sats={sats} (stub)")
|
||||
self._last_status_log = now
|
||||
self._fix_count = 0
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
178
pi/backend/lte_service.py
Normal file
178
pi/backend/lte_service.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""LTE service - polls ModemManager for signal quality and connection state."""
|
||||
|
||||
import random
|
||||
import subprocess
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
# ============================================================================
|
||||
# DEBUG MODE - Set True for development without modem hardware
|
||||
# When True: skips mmcli entirely, generates mock LTE data
|
||||
# When False: polls real ModemManager via mmcli
|
||||
# ============================================================================
|
||||
_LTE_DEBUG = True
|
||||
|
||||
|
||||
class LteService:
|
||||
"""Threaded LTE modem status poller.
|
||||
|
||||
Polls `mmcli -m 0` every 5 seconds, parses signal quality, connection
|
||||
state, operator, and access technology. Emits dict via callback.
|
||||
|
||||
No history buffer — historical signal strength isn't useful.
|
||||
"""
|
||||
|
||||
def __init__(self, poll_interval: float = 5.0):
|
||||
self.poll_interval = poll_interval
|
||||
|
||||
self._latest: dict[str, Any] = {}
|
||||
self._connected = False # True when mmcli responds (modem alive)
|
||||
self._running = False
|
||||
self._thread: threading.Thread | None = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# Callback for push-based updates
|
||||
self._on_data_callback = None
|
||||
|
||||
def set_on_data(self, callback):
|
||||
"""Set callback for new LTE data. Called with data dict."""
|
||||
self._on_data_callback = callback
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
"""Whether the modem is reachable via mmcli."""
|
||||
return self._connected
|
||||
|
||||
def get_latest(self) -> dict[str, Any]:
|
||||
"""Get most recent LTE status."""
|
||||
with self._lock:
|
||||
return self._latest.copy() if self._latest else {"error": "no data"}
|
||||
|
||||
def start(self):
|
||||
"""Start background LTE poller thread."""
|
||||
if self._running:
|
||||
return
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
|
||||
self._thread.start()
|
||||
print("[LTE] Service started")
|
||||
|
||||
def stop(self):
|
||||
"""Stop background poller."""
|
||||
self._running = False
|
||||
if self._thread:
|
||||
self._thread.join(timeout=2.0)
|
||||
|
||||
def _poll_loop(self):
|
||||
"""Main poll loop."""
|
||||
print("[LTE] Poller thread running")
|
||||
while self._running:
|
||||
try:
|
||||
if _LTE_DEBUG:
|
||||
data = self._stub_poll()
|
||||
else:
|
||||
data = self._real_poll()
|
||||
|
||||
with self._lock:
|
||||
self._latest = data
|
||||
|
||||
if self._on_data_callback:
|
||||
self._on_data_callback(data)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[LTE] Poll error: {e}")
|
||||
self._connected = False
|
||||
|
||||
time.sleep(self.poll_interval)
|
||||
|
||||
def _real_poll(self) -> dict[str, Any]:
|
||||
"""Poll mmcli -m 0 and parse output."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["mmcli", "-m", "0"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5.0,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
self._connected = False
|
||||
return {
|
||||
"connected": False,
|
||||
"signal": 0,
|
||||
"operator": None,
|
||||
"access_tech": None,
|
||||
}
|
||||
|
||||
output = result.stdout
|
||||
self._connected = True
|
||||
|
||||
# Parse signal quality: "signal quality: 68 (recent)"
|
||||
signal = 0
|
||||
m = re.search(r"signal quality:\s*(\d+)", output)
|
||||
if m:
|
||||
signal = int(m.group(1))
|
||||
|
||||
# Parse state: "state: connected" / "registered" / "searching" etc.
|
||||
state = None
|
||||
m = re.search(r"^\s*state:\s*(\S+)", output, re.MULTILINE)
|
||||
if m:
|
||||
state = m.group(1).strip("'\"")
|
||||
|
||||
# Parse operator: "operator name: KDDI KDDI"
|
||||
operator = None
|
||||
m = re.search(r"operator name:\s*(.+)", output)
|
||||
if m:
|
||||
operator = m.group(1).strip()
|
||||
|
||||
# Parse access tech: "access tech: lte"
|
||||
access_tech = None
|
||||
m = re.search(r"access tech:\s*(\S+)", output)
|
||||
if m:
|
||||
access_tech = m.group(1).strip("'\"")
|
||||
|
||||
network_connected = state in ("connected", "registered")
|
||||
|
||||
return {
|
||||
"connected": network_connected,
|
||||
"signal": signal,
|
||||
"operator": operator,
|
||||
"access_tech": access_tech,
|
||||
}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
print("[LTE] mmcli timed out")
|
||||
self._connected = False
|
||||
return {
|
||||
"connected": False,
|
||||
"signal": 0,
|
||||
"operator": None,
|
||||
"access_tech": None,
|
||||
}
|
||||
except FileNotFoundError:
|
||||
print("[LTE] mmcli not found, falling back to stub mode")
|
||||
self._connected = False
|
||||
return self._stub_poll()
|
||||
|
||||
# Stub state lives across polls
|
||||
_stub_signal: float = 70.0
|
||||
|
||||
def _stub_poll(self) -> dict[str, Any]:
|
||||
"""Generate mock LTE data for development.
|
||||
|
||||
Simulates connected state with signal wandering 60-80.
|
||||
"""
|
||||
self._connected = True
|
||||
|
||||
# Random walk signal, clamped to 60-80
|
||||
self._stub_signal += random.uniform(-3, 3)
|
||||
self._stub_signal = max(60, min(80, self._stub_signal))
|
||||
|
||||
return {
|
||||
"connected": True,
|
||||
"signal": int(self._stub_signal),
|
||||
"operator": "STUB",
|
||||
"access_tech": "lte",
|
||||
}
|
||||
@@ -1,236 +1,281 @@
|
||||
"""Smart Serow Backend - GPS and Arduino services with HTTP API and WebSocket."""
|
||||
|
||||
from gevent import monkey
|
||||
monkey.patch_all() # Must be at the very top before other imports
|
||||
|
||||
from flask import Flask, jsonify
|
||||
from flask_socketio import SocketIO, emit
|
||||
|
||||
from gps_service import GPSService
|
||||
from arduino_service import ArduinoService
|
||||
from throttle import Throttle
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["SECRET_KEY"] = "smartserow-secret" # Not security critical, just for session
|
||||
|
||||
# SocketIO with gevent async mode (eventlet is deprecated)
|
||||
socketio = SocketIO(app, async_mode="gevent", cors_allowed_origins="*")
|
||||
|
||||
# Services
|
||||
gps = GPSService()
|
||||
arduino = ArduinoService()
|
||||
|
||||
# Throttles for emission rate limiting (2Hz for arduino, 1Hz for GPS)
|
||||
arduino_throttle = Throttle(min_interval=0.5) # 2Hz max
|
||||
gps_throttle = Throttle(min_interval=1.0) # 1Hz max
|
||||
|
||||
# Track connected clients
|
||||
connected_clients = set()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# WebSocket Event Handlers
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@socketio.on("connect")
|
||||
def handle_connect():
|
||||
"""Client connected."""
|
||||
client_id = id(socketio) # Simple identifier
|
||||
connected_clients.add(client_id)
|
||||
print(f"[WS] Client connected ({len(connected_clients)} total)")
|
||||
|
||||
# Send current status immediately
|
||||
emit("status", {
|
||||
"gps_connected": gps.connected,
|
||||
"arduino_connected": arduino.connected,
|
||||
})
|
||||
|
||||
# Send latest data if available
|
||||
arduino_data = arduino.get_latest()
|
||||
if "error" not in arduino_data:
|
||||
emit("arduino", arduino_data)
|
||||
|
||||
gps_data = gps.get_latest()
|
||||
if "error" not in gps_data:
|
||||
emit("gps", gps_data)
|
||||
|
||||
|
||||
@socketio.on("disconnect")
|
||||
def handle_disconnect():
|
||||
"""Client disconnected."""
|
||||
client_id = id(socketio)
|
||||
connected_clients.discard(client_id)
|
||||
print(f"[WS] Client disconnected ({len(connected_clients)} remaining)")
|
||||
|
||||
|
||||
@socketio.on("button")
|
||||
def handle_button(data):
|
||||
"""Handle button press from UI.
|
||||
|
||||
Expected data: {"id": "horn", "action": "press", ...params}
|
||||
"""
|
||||
btn_id = data.get("id", "unknown")
|
||||
action = data.get("action", "press")
|
||||
params = {k: v for k, v in data.items() if k not in ("id", "action")}
|
||||
|
||||
print(f"[WS] Button: {btn_id} {action} {params}")
|
||||
|
||||
# Map button ID to Arduino command
|
||||
cmd_map = {
|
||||
"horn": "HORN",
|
||||
"light": "LIGHT",
|
||||
"indicator_left": "IND_L",
|
||||
"indicator_right": "IND_R",
|
||||
"hazard": "HAZARD",
|
||||
}
|
||||
|
||||
cmd = cmd_map.get(btn_id)
|
||||
if cmd:
|
||||
# Add action to params (e.g., ON/OFF based on press/release)
|
||||
params["state"] = "ON" if action == "press" else "OFF"
|
||||
success = arduino.send_command(cmd, params)
|
||||
|
||||
# Send immediate ack for the attempt
|
||||
emit("ack", {
|
||||
"id": btn_id,
|
||||
"status": "sent" if success else "failed",
|
||||
"error": None if success else "arduino not connected",
|
||||
})
|
||||
else:
|
||||
emit("ack", {
|
||||
"id": btn_id,
|
||||
"status": "error",
|
||||
"error": f"unknown button: {btn_id}",
|
||||
})
|
||||
|
||||
|
||||
@socketio.on("emergency")
|
||||
def handle_emergency(data):
|
||||
"""Handle emergency signal from UI."""
|
||||
etype = data.get("type", "stop")
|
||||
print(f"[WS] EMERGENCY: {etype}")
|
||||
|
||||
# Send emergency command to Arduino
|
||||
arduino.send_command("EMERGENCY", {"type": etype})
|
||||
|
||||
# Broadcast alert to all clients
|
||||
socketio.emit("alert", {
|
||||
"type": "emergency",
|
||||
"message": f"Emergency {etype} triggered",
|
||||
})
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Service Callbacks (push data to WebSocket)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def on_arduino_data(data):
|
||||
"""Called by ArduinoService when new telemetry arrives."""
|
||||
def emit_fn(d):
|
||||
socketio.emit("arduino", d)
|
||||
|
||||
arduino_throttle.maybe_emit(data, emit_fn)
|
||||
|
||||
|
||||
def on_gps_data(data):
|
||||
"""Called by GPSService when new fix arrives."""
|
||||
def emit_fn(d):
|
||||
socketio.emit("gps", d)
|
||||
|
||||
gps_throttle.maybe_emit(data, emit_fn)
|
||||
|
||||
|
||||
def on_arduino_ack(cmd, status, extra):
|
||||
"""Called by ArduinoService when ACK received from Arduino."""
|
||||
socketio.emit("ack", {
|
||||
"id": cmd.lower(),
|
||||
"status": status.lower(),
|
||||
"extra": extra,
|
||||
})
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Background task to flush pending throttled data
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def throttle_flusher():
|
||||
"""Periodically flush pending throttled data."""
|
||||
import gevent
|
||||
while True:
|
||||
gevent.sleep(0.5)
|
||||
|
||||
if arduino_throttle.has_pending:
|
||||
arduino_throttle.flush(lambda d: socketio.emit("arduino", d))
|
||||
|
||||
if gps_throttle.has_pending:
|
||||
gps_throttle.flush(lambda d: socketio.emit("gps", d))
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# REST API (backward compatibility)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@app.route("/health")
|
||||
def health():
|
||||
"""Health check endpoint."""
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"gps_connected": gps.connected,
|
||||
"arduino_connected": arduino.connected,
|
||||
"ws_clients": len(connected_clients),
|
||||
})
|
||||
|
||||
|
||||
@app.route("/gps")
|
||||
def gps_data():
|
||||
"""Current GPS data."""
|
||||
return jsonify(gps.get_latest())
|
||||
|
||||
|
||||
@app.route("/gps/history")
|
||||
def gps_history():
|
||||
"""Buffered GPS history."""
|
||||
return jsonify(gps.get_buffer())
|
||||
|
||||
|
||||
@app.route("/arduino")
|
||||
def arduino_data():
|
||||
"""Current Arduino telemetry (voltage, rpm, etc)."""
|
||||
return jsonify(arduino.get_latest())
|
||||
|
||||
|
||||
@app.route("/arduino/history")
|
||||
def arduino_history():
|
||||
"""Buffered Arduino telemetry history."""
|
||||
return jsonify(arduino.get_buffer())
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Main Entry Point
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
"""Entry point."""
|
||||
# Wire up callbacks
|
||||
arduino.set_on_data(on_arduino_data)
|
||||
arduino.set_on_ack(on_arduino_ack)
|
||||
gps.set_on_data(on_gps_data)
|
||||
|
||||
# Start services
|
||||
gps.start()
|
||||
arduino.start()
|
||||
|
||||
# Start throttle flusher in background
|
||||
socketio.start_background_task(throttle_flusher)
|
||||
|
||||
try:
|
||||
# Use socketio.run() instead of app.run() for WebSocket support
|
||||
print("[Backend] Starting on http://0.0.0.0:5000")
|
||||
socketio.run(app, host="0.0.0.0", port=5000, debug=False)
|
||||
finally:
|
||||
arduino.stop()
|
||||
gps.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
"""Smart Serow Backend - GPS and Arduino services with HTTP API and WebSocket."""
|
||||
|
||||
from gevent import monkey
|
||||
monkey.patch_all() # Must be at the very top before other imports
|
||||
|
||||
from flask import Flask, jsonify
|
||||
from flask_socketio import SocketIO, emit
|
||||
|
||||
from gps_service import GPSService
|
||||
from arduino_service import ArduinoService
|
||||
from gpio_service import GPIOService
|
||||
from lte_service import LteService
|
||||
from throttle import Throttle
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["SECRET_KEY"] = "smartserow-secret" # Not security critical, just for session
|
||||
|
||||
# SocketIO with gevent async mode (eventlet is deprecated)
|
||||
socketio = SocketIO(app, async_mode="gevent", cors_allowed_origins="*")
|
||||
|
||||
# Services
|
||||
gps = GPSService()
|
||||
arduino = ArduinoService()
|
||||
gpio = GPIOService()
|
||||
lte = LteService()
|
||||
|
||||
# Throttles for emission rate limiting (20Hz for arduino, 1Hz for GPS, 5s for LTE)
|
||||
arduino_throttle = Throttle(min_interval=0.05) # 20Hz max
|
||||
gps_throttle = Throttle(min_interval=1.0) # 1Hz max
|
||||
lte_throttle = Throttle(min_interval=5.0) # Every 5s — signal doesn't need real-time
|
||||
|
||||
# Track connected clients
|
||||
connected_clients = set()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# WebSocket Event Handlers
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@socketio.on("connect")
|
||||
def handle_connect():
|
||||
"""Client connected."""
|
||||
client_id = id(socketio) # Simple identifier
|
||||
connected_clients.add(client_id)
|
||||
print(f"[WS] Client connected ({len(connected_clients)} total)")
|
||||
|
||||
# Send current status immediately
|
||||
emit("status", {
|
||||
"gps_connected": gps.connected,
|
||||
"arduino_connected": arduino.connected,
|
||||
"theme_switch": gpio.theme_switch,
|
||||
})
|
||||
|
||||
# Send latest data if available
|
||||
arduino_data = arduino.get_latest()
|
||||
if "error" not in arduino_data:
|
||||
emit("arduino", arduino_data)
|
||||
|
||||
gps_data = gps.get_latest()
|
||||
if "error" not in gps_data:
|
||||
emit("gps", gps_data)
|
||||
|
||||
lte_data = lte.get_latest()
|
||||
if "error" not in lte_data:
|
||||
emit("lte", lte_data)
|
||||
|
||||
|
||||
@socketio.on("disconnect")
|
||||
def handle_disconnect():
|
||||
"""Client disconnected."""
|
||||
client_id = id(socketio)
|
||||
connected_clients.discard(client_id)
|
||||
print(f"[WS] Client disconnected ({len(connected_clients)} remaining)")
|
||||
|
||||
|
||||
@socketio.on("button")
|
||||
def handle_button(data):
|
||||
"""Handle button press from UI.
|
||||
|
||||
Expected data: {"id": "horn", "action": "press", ...params}
|
||||
"""
|
||||
btn_id = data.get("id", "unknown")
|
||||
action = data.get("action", "press")
|
||||
params = {k: v for k, v in data.items() if k not in ("id", "action")}
|
||||
|
||||
print(f"[WS] Button: {btn_id} {action} {params}")
|
||||
|
||||
# Map button ID to Arduino command
|
||||
cmd_map = {
|
||||
"horn": "HORN",
|
||||
"light": "LIGHT",
|
||||
"indicator_left": "IND_L",
|
||||
"indicator_right": "IND_R",
|
||||
"hazard": "HAZARD",
|
||||
}
|
||||
|
||||
cmd = cmd_map.get(btn_id)
|
||||
if cmd:
|
||||
# Add action to params (e.g., ON/OFF based on press/release)
|
||||
params["state"] = "ON" if action == "press" else "OFF"
|
||||
success = arduino.send_command(cmd, params)
|
||||
|
||||
# Send immediate ack for the attempt
|
||||
emit("ack", {
|
||||
"id": btn_id,
|
||||
"status": "sent" if success else "failed",
|
||||
"error": None if success else "arduino not connected",
|
||||
})
|
||||
else:
|
||||
emit("ack", {
|
||||
"id": btn_id,
|
||||
"status": "error",
|
||||
"error": f"unknown button: {btn_id}",
|
||||
})
|
||||
|
||||
|
||||
@socketio.on("emergency")
|
||||
def handle_emergency(data):
|
||||
"""Handle emergency signal from UI."""
|
||||
etype = data.get("type", "stop")
|
||||
print(f"[WS] EMERGENCY: {etype}")
|
||||
|
||||
# Send emergency command to Arduino
|
||||
arduino.send_command("EMERGENCY", {"type": etype})
|
||||
|
||||
# Broadcast alert to all clients
|
||||
socketio.emit("alert", {
|
||||
"type": "emergency",
|
||||
"message": f"Emergency {etype} triggered",
|
||||
})
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Service Callbacks (push data to WebSocket)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def on_arduino_data(data):
|
||||
"""Called by ArduinoService when new telemetry arrives."""
|
||||
# Always include current GPIO state (UI dedupes)
|
||||
data = dict(data) # Don't mutate original
|
||||
data["theme_switch"] = gpio.theme_switch
|
||||
|
||||
# backend voltage offset correction
|
||||
if "voltage" in data:
|
||||
data["voltage"] += 0.2 # Calibration offset
|
||||
|
||||
def emit_fn(d):
|
||||
socketio.emit("arduino", d)
|
||||
|
||||
arduino_throttle.maybe_emit(data, emit_fn)
|
||||
|
||||
|
||||
def on_gps_data(data):
|
||||
"""Called by GPSService when new fix arrives."""
|
||||
def emit_fn(d):
|
||||
socketio.emit("gps", d)
|
||||
|
||||
gps_throttle.maybe_emit(data, emit_fn)
|
||||
|
||||
|
||||
def on_lte_data(data):
|
||||
"""Called by LteService when new status polled."""
|
||||
def emit_fn(d):
|
||||
socketio.emit("lte", d)
|
||||
|
||||
lte_throttle.maybe_emit(data, emit_fn)
|
||||
|
||||
|
||||
def on_arduino_ack(cmd, status, extra):
|
||||
"""Called by ArduinoService when ACK received from Arduino."""
|
||||
socketio.emit("ack", {
|
||||
"id": cmd.lower(),
|
||||
"status": status.lower(),
|
||||
"extra": extra,
|
||||
})
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Background task to flush pending throttled data
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def throttle_flusher():
|
||||
"""Periodically flush pending throttled data."""
|
||||
import gevent
|
||||
while True:
|
||||
gevent.sleep(0.05) # 20Hz flush rate
|
||||
|
||||
if arduino_throttle.has_pending:
|
||||
arduino_throttle.flush(lambda d: socketio.emit("arduino", d))
|
||||
|
||||
if gps_throttle.has_pending:
|
||||
gps_throttle.flush(lambda d: socketio.emit("gps", d))
|
||||
|
||||
if lte_throttle.has_pending:
|
||||
lte_throttle.flush(lambda d: socketio.emit("lte", d))
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# REST API (backward compatibility)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@app.route("/health")
|
||||
def health():
|
||||
"""Health check endpoint."""
|
||||
gps_latest = gps.get_latest()
|
||||
lte_latest = lte.get_latest()
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"gps_connected": gps.connected,
|
||||
"gps_state": gps_latest.get("gps_state", "acquiring"),
|
||||
"arduino_connected": arduino.connected,
|
||||
"lte_connected": lte.connected,
|
||||
"lte_signal": lte_latest.get("signal"),
|
||||
"ws_clients": len(connected_clients),
|
||||
})
|
||||
|
||||
|
||||
@app.route("/gps")
|
||||
def gps_data():
|
||||
"""Current GPS data."""
|
||||
return jsonify(gps.get_latest())
|
||||
|
||||
|
||||
@app.route("/gps/history")
|
||||
def gps_history():
|
||||
"""Buffered GPS history."""
|
||||
return jsonify(gps.get_buffer())
|
||||
|
||||
|
||||
@app.route("/lte")
|
||||
def lte_data():
|
||||
"""Current LTE modem status."""
|
||||
return jsonify(lte.get_latest())
|
||||
|
||||
|
||||
@app.route("/arduino")
|
||||
def arduino_data():
|
||||
"""Current Arduino telemetry (voltage, rpm, etc)."""
|
||||
return jsonify(arduino.get_latest())
|
||||
|
||||
|
||||
@app.route("/arduino/history")
|
||||
def arduino_history():
|
||||
"""Buffered Arduino telemetry history."""
|
||||
return jsonify(arduino.get_buffer())
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Main Entry Point
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
def main():
|
||||
"""Entry point."""
|
||||
# Wire up callbacks
|
||||
arduino.set_on_data(on_arduino_data)
|
||||
arduino.set_on_ack(on_arduino_ack)
|
||||
gps.set_on_data(on_gps_data)
|
||||
lte.set_on_data(on_lte_data)
|
||||
|
||||
# Start services
|
||||
gps.start()
|
||||
arduino.start()
|
||||
gpio.start()
|
||||
lte.start()
|
||||
|
||||
# Start throttle flusher in background
|
||||
socketio.start_background_task(throttle_flusher)
|
||||
|
||||
try:
|
||||
# Use socketio.run() instead of app.run() for WebSocket support
|
||||
print("[Backend] Starting on http://0.0.0.0:5000")
|
||||
socketio.run(app, host="0.0.0.0", port=5000, debug=False)
|
||||
finally:
|
||||
arduino.stop()
|
||||
gps.stop()
|
||||
gpio.stop()
|
||||
lte.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
[project]
|
||||
name = "smartserow-backend"
|
||||
version = "0.1.0"
|
||||
description = "GPS and Arduino telemetry service for Smart Serow"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"flask>=3.0",
|
||||
"flask-socketio>=5.3.0",
|
||||
"gevent>=24.0",
|
||||
"gevent-websocket>=0.10",
|
||||
"gpsdclient>=1.3",
|
||||
"pyserial>=3.5",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"ruff",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
smartserow-backend = "main:main"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
[project]
|
||||
name = "smartserow-backend"
|
||||
version = "0.1.0"
|
||||
description = "GPS and Arduino telemetry service for Smart Serow"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"flask>=3.0",
|
||||
"flask-socketio>=5.3.0",
|
||||
"gevent>=24.0",
|
||||
"gevent-websocket>=0.10",
|
||||
"gpsdclient>=1.3",
|
||||
"pyserial>=3.5",
|
||||
# GPIO: install via apt (sudo apt install python3-rpi.gpio)
|
||||
# Not listed here because pip versions require compilation
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"ruff",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
smartserow-backend = "main:main"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
|
||||
@@ -1,61 +1,61 @@
|
||||
"""Throttle layer for rate-limiting telemetry emissions."""
|
||||
|
||||
import time
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
class Throttle:
|
||||
"""Rate limiter for WebSocket emissions.
|
||||
|
||||
Coalesces rapid updates - only emits at most once per min_interval.
|
||||
If multiple updates arrive within the interval, the latest value wins.
|
||||
"""
|
||||
|
||||
def __init__(self, min_interval: float = 0.5):
|
||||
"""
|
||||
Args:
|
||||
min_interval: Minimum seconds between emissions (default 0.5 = 2Hz max)
|
||||
"""
|
||||
self._last_emit: float = 0
|
||||
self._min_interval = min_interval
|
||||
self._pending: Any = None
|
||||
|
||||
def maybe_emit(self, data: Any, emit_fn: Callable[[Any], None]) -> bool:
|
||||
"""Emit if interval has passed, otherwise store as pending.
|
||||
|
||||
Args:
|
||||
data: Data to emit
|
||||
emit_fn: Function to call with data when emitting
|
||||
|
||||
Returns:
|
||||
True if emitted, False if stored as pending
|
||||
"""
|
||||
now = time.time()
|
||||
if now - self._last_emit >= self._min_interval:
|
||||
emit_fn(data)
|
||||
self._last_emit = now
|
||||
self._pending = None
|
||||
return True
|
||||
else:
|
||||
self._pending = data # Latest value wins
|
||||
return False
|
||||
|
||||
def flush(self, emit_fn: Callable[[Any], None]) -> bool:
|
||||
"""Emit pending data if any.
|
||||
|
||||
Call this periodically to ensure pending data gets sent.
|
||||
|
||||
Returns:
|
||||
True if pending data was emitted, False if nothing pending
|
||||
"""
|
||||
if self._pending is not None:
|
||||
emit_fn(self._pending)
|
||||
self._last_emit = time.time()
|
||||
self._pending = None
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def has_pending(self) -> bool:
|
||||
"""Check if there's pending data waiting to be emitted."""
|
||||
return self._pending is not None
|
||||
"""Throttle layer for rate-limiting telemetry emissions."""
|
||||
|
||||
import time
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
class Throttle:
|
||||
"""Rate limiter for WebSocket emissions.
|
||||
|
||||
Coalesces rapid updates - only emits at most once per min_interval.
|
||||
If multiple updates arrive within the interval, the latest value wins.
|
||||
"""
|
||||
|
||||
def __init__(self, min_interval: float = 0.5):
|
||||
"""
|
||||
Args:
|
||||
min_interval: Minimum seconds between emissions (default 0.5 = 2Hz max)
|
||||
"""
|
||||
self._last_emit: float = 0
|
||||
self._min_interval = min_interval
|
||||
self._pending: Any = None
|
||||
|
||||
def maybe_emit(self, data: Any, emit_fn: Callable[[Any], None]) -> bool:
|
||||
"""Emit if interval has passed, otherwise store as pending.
|
||||
|
||||
Args:
|
||||
data: Data to emit
|
||||
emit_fn: Function to call with data when emitting
|
||||
|
||||
Returns:
|
||||
True if emitted, False if stored as pending
|
||||
"""
|
||||
now = time.time()
|
||||
if now - self._last_emit >= self._min_interval:
|
||||
emit_fn(data)
|
||||
self._last_emit = now
|
||||
self._pending = None
|
||||
return True
|
||||
else:
|
||||
self._pending = data # Latest value wins
|
||||
return False
|
||||
|
||||
def flush(self, emit_fn: Callable[[Any], None]) -> bool:
|
||||
"""Emit pending data if any.
|
||||
|
||||
Call this periodically to ensure pending data gets sent.
|
||||
|
||||
Returns:
|
||||
True if pending data was emitted, False if nothing pending
|
||||
"""
|
||||
if self._pending is not None:
|
||||
emit_fn(self._pending)
|
||||
self._last_emit = time.time()
|
||||
self._pending = None
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def has_pending(self) -> bool:
|
||||
"""Check if there's pending data waiting to be emitted."""
|
||||
return self._pending is not None
|
||||
|
||||
49
pi/backend/utils/at_terminal.py
Normal file
49
pi/backend/utils/at_terminal.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Quick AT command terminal - minicom but less hostile.
|
||||
|
||||
Usage:
|
||||
python at_terminal.py [port]
|
||||
|
||||
Default port: /dev/ttyUSB2 (SIM7600 AT command interface)
|
||||
"""
|
||||
|
||||
import sys
|
||||
import serial
|
||||
import threading
|
||||
|
||||
PORT = sys.argv[1] if len(sys.argv) > 1 else "/dev/ttyUSB2"
|
||||
BAUD = 115200
|
||||
|
||||
|
||||
def reader(ser):
|
||||
"""Background thread: print everything the modem sends."""
|
||||
while True:
|
||||
try:
|
||||
data = ser.read(ser.in_waiting or 1)
|
||||
if data:
|
||||
sys.stdout.write(data.decode("utf-8", errors="replace"))
|
||||
sys.stdout.flush()
|
||||
except Exception:
|
||||
break
|
||||
|
||||
|
||||
def main():
|
||||
print(f"Opening {PORT} @ {BAUD} baud")
|
||||
print("Type AT commands. Ctrl+C to quit.\n")
|
||||
|
||||
ser = serial.Serial(PORT, BAUD, timeout=0.1)
|
||||
|
||||
t = threading.Thread(target=reader, args=(ser,), daemon=True)
|
||||
t.start()
|
||||
|
||||
try:
|
||||
while True:
|
||||
line = input()
|
||||
ser.write((line + "\r\n").encode())
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print("\nBye.")
|
||||
finally:
|
||||
ser.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -22,11 +22,30 @@ All services use singleton pattern with `ServiceName.instance`.
|
||||
| Service | Role |
|
||||
|---------|------|
|
||||
| `ConfigService` | Loads `config.json`, exposes settings |
|
||||
| `PiIO` | Pi hardware interface (CPU temp, future GPIO) |
|
||||
| `WebSocketService` | socket.io client, streams for arduino/gps/connection/debug, auto-reconnect |
|
||||
| `PiIO` | Pi hardware interface (CPU temp) |
|
||||
| `OverheatMonitor` | Polls temp, fires callback when threshold exceeded |
|
||||
| `ThemeService` | Dark/bright mode state, notifies listeners |
|
||||
| `TestFlipFlopService` | Debug: toggles theme + navigator emotion every 2s |
|
||||
|
||||
## Key Widgets
|
||||
|
||||
| Widget | Purpose |
|
||||
|--------|---------|
|
||||
| `NavigatorWidget` | Animated character with emotion states (images precached at startup) |
|
||||
| `AccelGraph` | Real-time accelerometer visualization with gravity compensation |
|
||||
| `GpsCompass` | GPS heading compass with rotating navigation icon and degree readout |
|
||||
| `WhiskeyMark` | Gimbal-style horizon indicator using IMU roll/pitch |
|
||||
| `SystemBar` | Top status bar (time, connection, Pi temp) |
|
||||
| `StatBox` | Reusable metric display box |
|
||||
| `DebugConsole` | Scrolling log overlay for diagnostics |
|
||||
|
||||
## Notes
|
||||
|
||||
- **Gravity compensation**: Accelerometer display subtracts 1g from Z-axis to show deviation from vertical
|
||||
- **Navigator precaching**: All navigator images are loaded during splash screen to prevent frame drops
|
||||
- **Theme switching**: Backend sends `theme_switch` via WebSocket status events (triggered by GPIO)
|
||||
|
||||
## Theme System
|
||||
|
||||
- `AppColors` — static color constants (dark/bright variants), auto-generated from JSON
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'screens/splash_screen.dart';
|
||||
import 'screens/dashboard_screen.dart';
|
||||
@@ -17,7 +19,7 @@ class AppRoot extends StatefulWidget {
|
||||
class _AppRootState extends State<AppRoot> {
|
||||
bool _initialized = false;
|
||||
bool _overheatTriggered = false;
|
||||
String _initStatus = 'Starting...';
|
||||
final Map<String, String> _initStatuses = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -31,25 +33,34 @@ class _AppRootState extends State<AppRoot> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _updateStatus(String key, String value) {
|
||||
setState(() => _initStatuses[key] = value);
|
||||
}
|
||||
|
||||
Future<void> _runInitSequence() async {
|
||||
// Load config first
|
||||
setState(() => _initStatus = 'Loading config...');
|
||||
// Show all items from the start so the row doesn't jump around
|
||||
_updateStatus('Config', '...');
|
||||
_updateStatus('UART', '...');
|
||||
_updateStatus('GPS', '...');
|
||||
_updateStatus('Navigator', '...');
|
||||
|
||||
// Config must load first (everything else depends on it)
|
||||
_updateStatus('Config', 'Loading');
|
||||
await ConfigService.instance.load();
|
||||
_updateStatus('Config', 'Ready');
|
||||
|
||||
// Simulate init checks - replace with real checks later
|
||||
// (UART, GPS, sensors, etc.)
|
||||
// UART, GPS, and navigator image preload run truly in parallel
|
||||
_updateStatus('UART', 'Connecting');
|
||||
_updateStatus('GPS', 'Waiting');
|
||||
_updateStatus('Navigator', 'Loading');
|
||||
await Future.wait([
|
||||
_waitForUart(),
|
||||
_waitForGps(),
|
||||
_preloadNavigatorImages(),
|
||||
]);
|
||||
|
||||
setState(() => _initStatus = 'Checking systems...');
|
||||
await Future.delayed(const Duration(milliseconds: 800));
|
||||
|
||||
setState(() => _initStatus = 'UART: standby');
|
||||
await Future.delayed(const Duration(milliseconds: 400));
|
||||
|
||||
setState(() => _initStatus = 'GPS: standby');
|
||||
await Future.delayed(const Duration(milliseconds: 400));
|
||||
|
||||
setState(() => _initStatus = 'Ready');
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
// Let the user see the all-ready state for a moment
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
// Start overheat monitoring
|
||||
OverheatMonitor.instance.start(
|
||||
@@ -61,6 +72,82 @@ class _AppRootState extends State<AppRoot> {
|
||||
setState(() => _initialized = true);
|
||||
}
|
||||
|
||||
/// Poll backend health endpoint until Arduino is connected
|
||||
Future<void> _waitForUart() async {
|
||||
final backendUrl = ConfigService.instance.backendUrl;
|
||||
const maxAttempts = 30; // ~30 seconds max wait
|
||||
const retryDelay = Duration(seconds: 1);
|
||||
|
||||
for (int attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
final response = await http
|
||||
.get(Uri.parse('$backendUrl/health'))
|
||||
.timeout(const Duration(seconds: 2));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
if (data['arduino_connected'] == true) {
|
||||
_updateStatus('UART', 'Ready');
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Backend not reachable yet - keep trying
|
||||
}
|
||||
|
||||
_updateStatus('UART', 'Waiting');
|
||||
await Future.delayed(retryDelay);
|
||||
}
|
||||
|
||||
// Timeout - proceed anyway (UI will show stale data indicators)
|
||||
_updateStatus('UART', 'Timeout');
|
||||
}
|
||||
|
||||
/// Poll backend health endpoint until GPS has a fix, or bail after 7.5s
|
||||
Future<void> _waitForGps() async {
|
||||
final backendUrl = ConfigService.instance.backendUrl;
|
||||
const bailOut = Duration(milliseconds: 7500);
|
||||
const retryDelay = Duration(seconds: 1);
|
||||
final deadline = DateTime.now().add(bailOut);
|
||||
|
||||
_updateStatus('GPS', 'Acquiring');
|
||||
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
try {
|
||||
final response = await http
|
||||
.get(Uri.parse('$backendUrl/health'))
|
||||
.timeout(const Duration(seconds: 2));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
if (data['gps_state'] == 'fix') {
|
||||
_updateStatus('GPS', 'Ready');
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Backend not reachable yet - keep trying
|
||||
}
|
||||
|
||||
await Future.delayed(retryDelay);
|
||||
}
|
||||
|
||||
// Bail out - dashboard will show live GPS state when it arrives
|
||||
_updateStatus('GPS', 'Timeout');
|
||||
}
|
||||
|
||||
/// Preload navigator images into Flutter's image cache
|
||||
///
|
||||
/// Scans for all PNGs in the navigator folder and precaches them.
|
||||
Future<void> _preloadNavigatorImages() async {
|
||||
final images = await ConfigService.instance.getNavigatorImages();
|
||||
for (final file in images) {
|
||||
if (!mounted) return;
|
||||
await precacheImage(FileImage(file), context);
|
||||
}
|
||||
_updateStatus('Navigator', 'Ready');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Determine which screen to show (priority: overheat > splash > dashboard)
|
||||
@@ -68,7 +155,7 @@ class _AppRootState extends State<AppRoot> {
|
||||
if (_overheatTriggered) {
|
||||
child = const OverheatScreen(key: ValueKey('overheat'));
|
||||
} else if (!_initialized) {
|
||||
child = SplashScreen(key: const ValueKey('splash'), status: _initStatus);
|
||||
child = SplashScreen(key: const ValueKey('splash'), statuses: _initStatuses);
|
||||
} else {
|
||||
child = const DashboardScreen(key: ValueKey('dashboard'));
|
||||
}
|
||||
|
||||
@@ -1,237 +1,298 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../services/backend_service.dart';
|
||||
import '../services/websocket_service.dart';
|
||||
import '../services/pi_io.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../widgets/navigator_widget.dart';
|
||||
import '../widgets/stat_box.dart';
|
||||
import '../widgets/stat_box_main.dart';
|
||||
import '../widgets/system_bar.dart';
|
||||
import '../widgets/debug_console.dart';
|
||||
|
||||
// test service for triggers
|
||||
import '../services/test_flipflop_service.dart';
|
||||
|
||||
/// Main dashboard - displays Pi vitals and placeholder stats
|
||||
class DashboardScreen extends StatefulWidget {
|
||||
const DashboardScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DashboardScreen> createState() => _DashboardScreenState();
|
||||
}
|
||||
|
||||
class _DashboardScreenState extends State<DashboardScreen> {
|
||||
final _navigatorKey = GlobalKey<NavigatorWidgetState>();
|
||||
|
||||
// Timer for Pi temp only (safety critical, direct file read)
|
||||
Timer? _piTempTimer;
|
||||
|
||||
// WebSocket stream subscriptions
|
||||
StreamSubscription<ArduinoData>? _arduinoSub;
|
||||
StreamSubscription<GpsData>? _gpsSub;
|
||||
StreamSubscription<WsConnectionState>? _connectionSub;
|
||||
|
||||
// Pi temperature - direct file read (safety critical)
|
||||
double? _piTemp;
|
||||
|
||||
// From backend - Arduino data
|
||||
int? _rpm;
|
||||
double? _voltage;
|
||||
int? _engineTemp;
|
||||
int? _gear;
|
||||
|
||||
// From backend - GPS data
|
||||
double? _gpsSpeed;
|
||||
|
||||
// Placeholder values for system bar
|
||||
int? _gpsSatellites;
|
||||
int? _lteSignal;
|
||||
|
||||
// WebSocket connection state
|
||||
WsConnectionState _wsState = WsConnectionState.disconnected;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Connect to WebSocket
|
||||
WebSocketService.instance.connect();
|
||||
|
||||
// Subscribe to Arduino data stream
|
||||
_arduinoSub = WebSocketService.instance.arduinoStream.listen((data) {
|
||||
setState(() {
|
||||
_voltage = data.voltage;
|
||||
_rpm = data.rpm;
|
||||
_engineTemp = data.engTemp;
|
||||
_gear = data.gear;
|
||||
});
|
||||
});
|
||||
|
||||
// Subscribe to GPS data stream
|
||||
_gpsSub = WebSocketService.instance.gpsStream.listen((data) {
|
||||
setState(() {
|
||||
_gpsSpeed = data.speed;
|
||||
// Derive satellites from mode (placeholder logic)
|
||||
_gpsSatellites = data.mode == 3 ? 8 : (data.mode == 2 ? 4 : 0);
|
||||
});
|
||||
});
|
||||
|
||||
// Subscribe to connection state
|
||||
_connectionSub = WebSocketService.instance.connectionStream.listen((state) {
|
||||
setState(() {
|
||||
_wsState = state;
|
||||
});
|
||||
});
|
||||
|
||||
// Timer for Pi temp only (safety critical - bypasses backend)
|
||||
_piTempTimer = Timer.periodic(const Duration(milliseconds: 500), (_) {
|
||||
setState(() {
|
||||
_piTemp = PiIO.instance.getTemperature();
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize with any cached data from WebSocketService
|
||||
final cachedArduino = WebSocketService.instance.latestArduino;
|
||||
if (cachedArduino != null) {
|
||||
_voltage = cachedArduino.voltage;
|
||||
_rpm = cachedArduino.rpm;
|
||||
_engineTemp = cachedArduino.engTemp;
|
||||
_gear = cachedArduino.gear;
|
||||
}
|
||||
|
||||
final cachedGps = WebSocketService.instance.latestGps;
|
||||
if (cachedGps != null) {
|
||||
_gpsSpeed = cachedGps.speed;
|
||||
_gpsSatellites = cachedGps.mode == 3 ? 8 : (cachedGps.mode == 2 ? 4 : 0);
|
||||
}
|
||||
|
||||
_wsState = WebSocketService.instance.connectionState;
|
||||
|
||||
// Placeholder: LTE signal (TODO: wire up when LTE service exists)
|
||||
_lteSignal = null;
|
||||
|
||||
// DEBUG: flip-flop theme + navigator every 2s
|
||||
TestFlipFlopService.instance.start(navigatorKey: _navigatorKey);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_piTempTimer?.cancel();
|
||||
_arduinoSub?.cancel();
|
||||
_gpsSub?.cancel();
|
||||
_connectionSub?.cancel();
|
||||
TestFlipFlopService.instance.stop();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Format gear for display: null → "—", 0 → "N", 1-6 → "1"-"6"
|
||||
String _formatGear(int? gear) {
|
||||
if (gear == null) return '—';
|
||||
if (gear == 0) return 'N';
|
||||
return gear.toString();
|
||||
}
|
||||
|
||||
/// Format nullable int for display
|
||||
String _formatInt(int? value) => value?.toString() ?? '—';
|
||||
|
||||
/// Format nullable double for display with decimal places
|
||||
String _formatDouble(double? value, [int decimals = 1]) {
|
||||
if (value == null) return '—';
|
||||
return value.toStringAsFixed(decimals);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppTheme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: theme.background,
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
// Left side: All dashboard widgets (flex: 2)
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// System status bar
|
||||
SystemBar(
|
||||
gpsSatellites: _gpsSatellites,
|
||||
lteSignal: _lteSignal,
|
||||
piTemp: _piTemp,
|
||||
voltage: _voltage,
|
||||
wsState: _wsState,
|
||||
),
|
||||
|
||||
const SizedBox(height: 5),
|
||||
|
||||
// Main content area - big stat boxes
|
||||
Expanded(
|
||||
flex: 8,
|
||||
child: Row(
|
||||
children: [
|
||||
// RPM from Arduino
|
||||
StatBoxMain(
|
||||
value: _formatInt(_rpm),
|
||||
label: 'RPM',
|
||||
),
|
||||
// Add second StatBoxMain here for 2-up layout:
|
||||
// StatBoxMain(value: '4500', unit: 'rpm', label: 'TACH'),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom stats row
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
StatBox(value: _formatInt(_rpm), label: 'RPM'),
|
||||
StatBox(value: _formatInt(_engineTemp), unit: '°C', label: 'ENG'),
|
||||
StatBox(value: _formatGear(_gear), label: 'GEAR'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 32),
|
||||
|
||||
// Right side: Navigator on top, debug console below
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Column(
|
||||
children: [
|
||||
// Navigator
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Center(
|
||||
child: NavigatorWidget(key: _navigatorKey),
|
||||
),
|
||||
),
|
||||
// Debug console
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child:
|
||||
DebugConsole(
|
||||
messageStream: WebSocketService.instance.debugStream,
|
||||
initialMessages: WebSocketService.instance.debugMessages,
|
||||
maxLines: 6,
|
||||
title: 'WebSocket messages',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'dart:async';
|
||||
import 'dart:math' show sqrt, sin, cos, pi;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../services/backend_service.dart';
|
||||
import '../services/websocket_service.dart';
|
||||
import '../services/pi_io.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../widgets/navigator_widget.dart';
|
||||
import '../widgets/stat_box.dart';
|
||||
import '../widgets/stat_box_main.dart';
|
||||
import '../widgets/system_bar.dart';
|
||||
import '../widgets/debug_console.dart';
|
||||
import '../widgets/whiskey_mark.dart';
|
||||
import '../widgets/accel_graph.dart';
|
||||
import '../widgets/gps_compass.dart';
|
||||
|
||||
// test service for triggers
|
||||
import '../services/test_flipflop_service.dart';
|
||||
|
||||
/// Main dashboard - displays Pi vitals and placeholder stats
|
||||
class DashboardScreen extends StatefulWidget {
|
||||
const DashboardScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DashboardScreen> createState() => _DashboardScreenState();
|
||||
}
|
||||
|
||||
class _DashboardScreenState extends State<DashboardScreen> {
|
||||
static const _surpriseThreshold = 0.24; // G threshold for navigator surprise
|
||||
|
||||
final _navigatorKey = GlobalKey<NavigatorWidgetState>();
|
||||
|
||||
// Timer for Pi temp only (safety critical, direct file read)
|
||||
Timer? _piTempTimer;
|
||||
|
||||
// WebSocket stream subscriptions
|
||||
StreamSubscription<ArduinoData>? _arduinoSub;
|
||||
StreamSubscription<GpsData>? _gpsSub;
|
||||
StreamSubscription<LteData>? _lteSub;
|
||||
StreamSubscription<WsConnectionState>? _connectionSub;
|
||||
|
||||
// Pi temperature - direct file read (safety critical)
|
||||
double? _piTemp;
|
||||
|
||||
// From backend - Arduino data
|
||||
int? _rpm;
|
||||
double? _voltage;
|
||||
int? _engineTemp;
|
||||
int? _gear;
|
||||
double? _roll;
|
||||
double? _pitch;
|
||||
double? _ax;
|
||||
double? _ay;
|
||||
double? _dynamicAx; // Gravity-compensated
|
||||
double? _dynamicAy;
|
||||
|
||||
// From backend - GPS data
|
||||
double? _gpsSpeed;
|
||||
double? _gpsTrack;
|
||||
|
||||
// Placeholder values for system bar
|
||||
int? _gpsSatellites;
|
||||
String? _gpsState;
|
||||
int? _lteSignal;
|
||||
|
||||
// WebSocket connection state
|
||||
WsConnectionState _wsState = WsConnectionState.disconnected;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Connect to WebSocket
|
||||
WebSocketService.instance.connect();
|
||||
|
||||
// Subscribe to Arduino data stream
|
||||
_arduinoSub = WebSocketService.instance.arduinoStream.listen((data) {
|
||||
// Gravity-compensated acceleration
|
||||
// When tilted, gravity "leaks" into horizontal axes - subtract it out
|
||||
final rollRad = (data.roll ?? 0) * pi / 180;
|
||||
final pitchRad = (data.pitch ?? 0) * pi / 180;
|
||||
|
||||
// Subtract gravity leakage from measured acceleration
|
||||
// Axes swapped for IMU mounting orientation
|
||||
final dynamicAx = (data.ay ?? 0) + sin(rollRad);
|
||||
final dynamicAy = (data.ax ?? 0) - (sin(pitchRad) * cos(rollRad));
|
||||
|
||||
setState(() {
|
||||
_voltage = data.voltage;
|
||||
_rpm = data.rpm;
|
||||
_engineTemp = data.engTemp;
|
||||
_gear = data.gear;
|
||||
_roll = data.roll;
|
||||
_pitch = data.pitch;
|
||||
_ax = data.ax;
|
||||
_ay = data.ay;
|
||||
_dynamicAx = dynamicAx;
|
||||
_dynamicAy = dynamicAy;
|
||||
});
|
||||
|
||||
final gMagnitude = sqrt(dynamicAx * dynamicAx + dynamicAy * dynamicAy);
|
||||
if (gMagnitude > _surpriseThreshold) {
|
||||
_navigatorKey.currentState?.setEmotion('surprise');
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to GPS data stream
|
||||
_gpsSub = WebSocketService.instance.gpsStream.listen((data) {
|
||||
setState(() {
|
||||
_gpsSpeed = data.speed;
|
||||
_gpsTrack = data.track;
|
||||
_gpsSatellites = data.satellites;
|
||||
_gpsState = data.gpsState;
|
||||
});
|
||||
});
|
||||
|
||||
// Subscribe to LTE data stream
|
||||
_lteSub = WebSocketService.instance.lteStream.listen((data) {
|
||||
setState(() {
|
||||
_lteSignal = data.signal;
|
||||
});
|
||||
});
|
||||
|
||||
// Subscribe to connection state
|
||||
_connectionSub = WebSocketService.instance.connectionStream.listen((state) {
|
||||
setState(() {
|
||||
_wsState = state;
|
||||
});
|
||||
});
|
||||
|
||||
// Timer for Pi temp only (safety critical - bypasses backend)
|
||||
_piTempTimer = Timer.periodic(const Duration(milliseconds: 500), (_) {
|
||||
setState(() {
|
||||
_piTemp = PiIO.instance.getTemperature();
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize with any cached data from WebSocketService
|
||||
final cachedArduino = WebSocketService.instance.latestArduino;
|
||||
if (cachedArduino != null) {
|
||||
_voltage = cachedArduino.voltage;
|
||||
_rpm = cachedArduino.rpm;
|
||||
_engineTemp = cachedArduino.engTemp;
|
||||
_gear = cachedArduino.gear;
|
||||
_roll = cachedArduino.roll;
|
||||
_pitch = cachedArduino.pitch;
|
||||
_ax = cachedArduino.ax;
|
||||
_ay = cachedArduino.ay;
|
||||
}
|
||||
|
||||
final cachedGps = WebSocketService.instance.latestGps;
|
||||
if (cachedGps != null) {
|
||||
_gpsSpeed = cachedGps.speed;
|
||||
_gpsTrack = cachedGps.track;
|
||||
_gpsSatellites = cachedGps.satellites;
|
||||
_gpsState = cachedGps.gpsState;
|
||||
}
|
||||
|
||||
_wsState = WebSocketService.instance.connectionState;
|
||||
|
||||
// Init from cached LTE data
|
||||
final cachedLte = WebSocketService.instance.latestLte;
|
||||
if (cachedLte != null) {
|
||||
_lteSignal = cachedLte.signal;
|
||||
}
|
||||
|
||||
// DEBUG: flip-flop theme + navigator every 2s
|
||||
TestFlipFlopService.instance.start(navigatorKey: _navigatorKey);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_piTempTimer?.cancel();
|
||||
_arduinoSub?.cancel();
|
||||
_gpsSub?.cancel();
|
||||
_lteSub?.cancel();
|
||||
_connectionSub?.cancel();
|
||||
TestFlipFlopService.instance.stop();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Format gear for display: null → "—", 0 → "N", 1-6 → "1"-"6"
|
||||
String _formatGear(int? gear) {
|
||||
if (gear == null) return '—';
|
||||
if (gear == 0) return 'N';
|
||||
return gear.toString();
|
||||
}
|
||||
|
||||
/// Format nullable int for display
|
||||
String _formatInt(int? value) => value?.toString() ?? '—';
|
||||
|
||||
/// Format nullable double for display with decimal places
|
||||
String _formatDouble(double? value, [int decimals = 1]) {
|
||||
if (value == null) return '—';
|
||||
return value.toStringAsFixed(decimals);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppTheme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: theme.background,
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
// Left side: All dashboard widgets (flex: 2)
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// System status bar
|
||||
SystemBar(
|
||||
gpsSatellites: _gpsSatellites,
|
||||
gpsState: _gpsState,
|
||||
lteSignal: _lteSignal,
|
||||
piTemp: _piTemp,
|
||||
voltage: _voltage,
|
||||
wsState: _wsState,
|
||||
),
|
||||
|
||||
// Main content area - big widgets
|
||||
Expanded(
|
||||
flex: 7,
|
||||
child: Row(
|
||||
children: [
|
||||
// Attitude indicator (whiskey mark)
|
||||
Expanded(
|
||||
child: WhiskeyMark(
|
||||
roll: _roll,
|
||||
pitch: _pitch,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: AccelGraph(
|
||||
ax: _dynamicAx, // Gravity-compensated lateral
|
||||
ay: _dynamicAy, // Gravity-compensated longitudinal
|
||||
maxG: 0.8,
|
||||
ghostTrackPeriod: const Duration(seconds: 4),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom stats row
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
StatBox(value: _formatInt(_rpm), label: 'RPM', isWarning: () => (_rpm ?? 0) > 4000),
|
||||
GpsCompass(heading: _gpsTrack, gpsState: _gpsState),
|
||||
StatBox(value: _formatGear(_gear), label: 'GEAR'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 32),
|
||||
|
||||
// Right side: Navigator on top, debug console below
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Column(
|
||||
children: [
|
||||
// Navigator
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Center(
|
||||
child: NavigatorWidget(key: _navigatorKey),
|
||||
),
|
||||
),
|
||||
// Debug console
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child:
|
||||
DebugConsole(
|
||||
messageStream: WebSocketService.instance.debugStream,
|
||||
initialMessages: WebSocketService.instance.debugMessages,
|
||||
maxLines: 6,
|
||||
title: 'WebSocket messages',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ import 'package:flutter/material.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
/// Splash screen - shown during initialization
|
||||
///
|
||||
/// Displays parallel status items that independently flip to "Ready".
|
||||
class SplashScreen extends StatelessWidget {
|
||||
final String status;
|
||||
final Map<String, String> statuses;
|
||||
|
||||
const SplashScreen({super.key, required this.status});
|
||||
const SplashScreen({super.key, required this.statuses});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -33,13 +35,19 @@ class SplashScreen extends StatelessWidget {
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
status,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontSize: 80,
|
||||
color: theme.subdued,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: statuses.entries.map((entry) {
|
||||
final isReady = entry.value == 'Ready';
|
||||
return Text(
|
||||
'${entry.key}: ${entry.value}',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontSize: 48,
|
||||
color: isReady ? theme.foreground : theme.subdued,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,149 +1,182 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Data from Arduino (voltage, rpm, engine temp, gear)
|
||||
class ArduinoData {
|
||||
final double? voltage;
|
||||
final int? rpm;
|
||||
final int? engTemp;
|
||||
final int? gear; // 0 = neutral, 1-6 = gear
|
||||
|
||||
ArduinoData({this.voltage, this.rpm, this.engTemp, this.gear});
|
||||
|
||||
factory ArduinoData.fromJson(Map<String, dynamic> json) {
|
||||
return ArduinoData(
|
||||
voltage: (json['voltage'] as num?)?.toDouble(),
|
||||
rpm: (json['rpm'] as num?)?.toInt(),
|
||||
engTemp: (json['eng_temp'] as num?)?.toInt(),
|
||||
gear: (json['gear'] as num?)?.toInt(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Data from GPS
|
||||
class GpsData {
|
||||
final double? lat;
|
||||
final double? lon;
|
||||
final double? speed; // m/s
|
||||
final double? alt;
|
||||
final double? track;
|
||||
final int? mode; // 0=no fix, 2=2D, 3=3D
|
||||
|
||||
GpsData({this.lat, this.lon, this.speed, this.alt, this.track, this.mode});
|
||||
|
||||
factory GpsData.fromJson(Map<String, dynamic> json) {
|
||||
return GpsData(
|
||||
lat: (json['lat'] as num?)?.toDouble(),
|
||||
lon: (json['lon'] as num?)?.toDouble(),
|
||||
speed: (json['speed'] as num?)?.toDouble(),
|
||||
alt: (json['alt'] as num?)?.toDouble(),
|
||||
track: (json['track'] as num?)?.toDouble(),
|
||||
mode: (json['mode'] as num?)?.toInt(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP client for Flask backend - fire-and-forget async fetch, sync cache return
|
||||
///
|
||||
/// Follows the same pattern as PiIO: never blocks UI, always returns cached data.
|
||||
class BackendService {
|
||||
BackendService._() {
|
||||
// Kick off initial fetches
|
||||
_refreshArduino();
|
||||
_refreshGps();
|
||||
}
|
||||
static final instance = BackendService._();
|
||||
|
||||
static const _baseUrl = 'http://127.0.0.1:5000';
|
||||
static const _timeout = Duration(seconds: 2);
|
||||
|
||||
// Caches
|
||||
ArduinoData? _arduinoCache;
|
||||
GpsData? _gpsCache;
|
||||
bool _connected = false;
|
||||
|
||||
// In-progress flags (prevent duplicate requests)
|
||||
bool _arduinoFetchInProgress = false;
|
||||
bool _gpsFetchInProgress = false;
|
||||
|
||||
/// Whether backend is reachable
|
||||
bool get isConnected => _connected;
|
||||
|
||||
/// Get Arduino data (sync, returns cached value)
|
||||
ArduinoData? getArduinoData() {
|
||||
if (!_arduinoFetchInProgress) {
|
||||
_refreshArduino();
|
||||
}
|
||||
return _arduinoCache;
|
||||
}
|
||||
|
||||
/// Get GPS data (sync, returns cached value)
|
||||
GpsData? getGpsData() {
|
||||
if (!_gpsFetchInProgress) {
|
||||
_refreshGps();
|
||||
}
|
||||
return _gpsCache;
|
||||
}
|
||||
|
||||
/// Background fetch for Arduino data
|
||||
Future<void> _refreshArduino() async {
|
||||
if (_arduinoFetchInProgress) return;
|
||||
_arduinoFetchInProgress = true;
|
||||
|
||||
try {
|
||||
final response = await http
|
||||
.get(Uri.parse('$_baseUrl/arduino'))
|
||||
.timeout(_timeout);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
// Skip if backend returns error (no data yet) - keep cached value
|
||||
if (!json.containsKey('error')) {
|
||||
_arduinoCache = ArduinoData.fromJson(json);
|
||||
}
|
||||
_connected = true;
|
||||
}
|
||||
// Non-200: keep cached data, just mark disconnected
|
||||
} catch (e) {
|
||||
// Network error, timeout, etc - keep cached data for transient hiccups
|
||||
_connected = false;
|
||||
} finally {
|
||||
_arduinoFetchInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Background fetch for GPS data
|
||||
Future<void> _refreshGps() async {
|
||||
if (_gpsFetchInProgress) return;
|
||||
_gpsFetchInProgress = true;
|
||||
|
||||
try {
|
||||
final response = await http
|
||||
.get(Uri.parse('$_baseUrl/gps'))
|
||||
.timeout(_timeout);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
// Skip if backend returns error (no data yet) - keep cached value
|
||||
if (!json.containsKey('error')) {
|
||||
_gpsCache = GpsData.fromJson(json);
|
||||
}
|
||||
_connected = true;
|
||||
}
|
||||
// Non-200: keep cached data, just mark disconnected
|
||||
} catch (e) {
|
||||
// Network error, timeout, etc - keep cached data for transient hiccups
|
||||
_connected = false;
|
||||
} finally {
|
||||
_gpsFetchInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Force clear all caches
|
||||
void clearCache() {
|
||||
_arduinoCache = null;
|
||||
_gpsCache = null;
|
||||
_connected = false;
|
||||
}
|
||||
}
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Data from Arduino (voltage, rpm, engine temp, gear, IMU)
|
||||
class ArduinoData {
|
||||
final double? voltage;
|
||||
final int? rpm;
|
||||
final int? engTemp;
|
||||
final int? gear; // 0 = neutral, 1-6 = gear
|
||||
final double? roll; // Euler angle in degrees (negative = left, positive = right)
|
||||
final double? pitch; // Euler angle in degrees (negative = nose down)
|
||||
final double? ax; // Lateral acceleration (g)
|
||||
final double? ay; // Longitudinal acceleration (g)
|
||||
final double? az; // Vertical acceleration (g)
|
||||
|
||||
ArduinoData({this.voltage, this.rpm, this.engTemp, this.gear, this.roll, this.pitch, this.ax, this.ay, this.az});
|
||||
|
||||
factory ArduinoData.fromJson(Map<String, dynamic> json) {
|
||||
return ArduinoData(
|
||||
voltage: (json['voltage'] as num?)?.toDouble(),
|
||||
rpm: (json['rpm'] as num?)?.toInt(),
|
||||
engTemp: (json['eng_temp'] as num?)?.toInt(),
|
||||
gear: (json['gear'] as num?)?.toInt(),
|
||||
roll: (json['roll'] as num?)?.toDouble(), // IMU mounted with axes swapped
|
||||
pitch: (json['pitch'] as num?)?.toDouble(),
|
||||
ax: (json['ax'] as num?)?.toDouble(),
|
||||
ay: (json['ay'] as num?)?.toDouble(),
|
||||
az: (json['az'] as num?)?.toDouble(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Data from GPS
|
||||
class GpsData {
|
||||
final double? lat;
|
||||
final double? lon;
|
||||
final double? speed; // m/s
|
||||
final double? alt;
|
||||
final double? track;
|
||||
final int? mode; // 0=no fix, 2=2D, 3=3D
|
||||
final int? satellites;
|
||||
final String? gpsState; // "acquiring", "fix", or "lost"
|
||||
|
||||
GpsData({this.lat, this.lon, this.speed, this.alt, this.track, this.mode, this.satellites, this.gpsState});
|
||||
|
||||
factory GpsData.fromJson(Map<String, dynamic> json) {
|
||||
return GpsData(
|
||||
lat: (json['lat'] as num?)?.toDouble(),
|
||||
lon: (json['lon'] as num?)?.toDouble(),
|
||||
speed: (json['speed'] as num?)?.toDouble(),
|
||||
alt: (json['alt'] as num?)?.toDouble(),
|
||||
track: (json['track'] as num?)?.toDouble(),
|
||||
mode: (json['mode'] as num?)?.toInt(),
|
||||
satellites: (json['satellites'] as num?)?.toInt(),
|
||||
gpsState: json['gps_state'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Data from LTE modem (signal quality, connection state)
|
||||
class LteData {
|
||||
final bool? connected;
|
||||
final int? signal; // 0-100 percent
|
||||
final String? operator_;
|
||||
final String? accessTech;
|
||||
|
||||
LteData({this.connected, this.signal, this.operator_, this.accessTech});
|
||||
|
||||
factory LteData.fromJson(Map<String, dynamic> json) {
|
||||
return LteData(
|
||||
connected: json['connected'] as bool?,
|
||||
signal: (json['signal'] as num?)?.toInt(),
|
||||
operator_: json['operator'] as String?,
|
||||
accessTech: json['access_tech'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP client for Flask backend - fire-and-forget async fetch, sync cache return
|
||||
///
|
||||
/// Follows the same pattern as PiIO: never blocks UI, always returns cached data.
|
||||
class BackendService {
|
||||
BackendService._() {
|
||||
// Kick off initial fetches
|
||||
_refreshArduino();
|
||||
_refreshGps();
|
||||
}
|
||||
static final instance = BackendService._();
|
||||
|
||||
static const _baseUrl = 'http://127.0.0.1:5000';
|
||||
static const _timeout = Duration(seconds: 2);
|
||||
|
||||
// Caches
|
||||
ArduinoData? _arduinoCache;
|
||||
GpsData? _gpsCache;
|
||||
bool _connected = false;
|
||||
|
||||
// In-progress flags (prevent duplicate requests)
|
||||
bool _arduinoFetchInProgress = false;
|
||||
bool _gpsFetchInProgress = false;
|
||||
|
||||
/// Whether backend is reachable
|
||||
bool get isConnected => _connected;
|
||||
|
||||
/// Get Arduino data (sync, returns cached value)
|
||||
ArduinoData? getArduinoData() {
|
||||
if (!_arduinoFetchInProgress) {
|
||||
_refreshArduino();
|
||||
}
|
||||
return _arduinoCache;
|
||||
}
|
||||
|
||||
/// Get GPS data (sync, returns cached value)
|
||||
GpsData? getGpsData() {
|
||||
if (!_gpsFetchInProgress) {
|
||||
_refreshGps();
|
||||
}
|
||||
return _gpsCache;
|
||||
}
|
||||
|
||||
/// Background fetch for Arduino data
|
||||
Future<void> _refreshArduino() async {
|
||||
if (_arduinoFetchInProgress) return;
|
||||
_arduinoFetchInProgress = true;
|
||||
|
||||
try {
|
||||
final response = await http
|
||||
.get(Uri.parse('$_baseUrl/arduino'))
|
||||
.timeout(_timeout);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
// Skip if backend returns error (no data yet) - keep cached value
|
||||
if (!json.containsKey('error')) {
|
||||
_arduinoCache = ArduinoData.fromJson(json);
|
||||
}
|
||||
_connected = true;
|
||||
}
|
||||
// Non-200: keep cached data, just mark disconnected
|
||||
} catch (e) {
|
||||
// Network error, timeout, etc - keep cached data for transient hiccups
|
||||
_connected = false;
|
||||
} finally {
|
||||
_arduinoFetchInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Background fetch for GPS data
|
||||
Future<void> _refreshGps() async {
|
||||
if (_gpsFetchInProgress) return;
|
||||
_gpsFetchInProgress = true;
|
||||
|
||||
try {
|
||||
final response = await http
|
||||
.get(Uri.parse('$_baseUrl/gps'))
|
||||
.timeout(_timeout);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
// Skip if backend returns error (no data yet) - keep cached value
|
||||
if (!json.containsKey('error')) {
|
||||
_gpsCache = GpsData.fromJson(json);
|
||||
}
|
||||
_connected = true;
|
||||
}
|
||||
// Non-200: keep cached data, just mark disconnected
|
||||
} catch (e) {
|
||||
// Network error, timeout, etc - keep cached data for transient hiccups
|
||||
_connected = false;
|
||||
} finally {
|
||||
_gpsFetchInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Force clear all caches
|
||||
void clearCache() {
|
||||
_arduinoCache = null;
|
||||
_gpsCache = null;
|
||||
_connected = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,4 +86,26 @@ class ConfigService {
|
||||
if (value is String && value.isNotEmpty) return value;
|
||||
return _defaultNavigator;
|
||||
}
|
||||
|
||||
/// Backend URL for API calls
|
||||
String get backendUrl {
|
||||
final value = _config?['backend_url'];
|
||||
if (value is String && value.isNotEmpty) return value;
|
||||
return 'http://127.0.0.1:5000';
|
||||
}
|
||||
|
||||
/// Get list of all navigator image files
|
||||
///
|
||||
/// Scans the navigator directory for PNG files.
|
||||
/// Returns empty list if directory doesn't exist.
|
||||
Future<List<File>> getNavigatorImages() async {
|
||||
final dir = Directory('$assetsPath${Platform.pathSeparator}navigator${Platform.pathSeparator}$navigator');
|
||||
if (!await dir.exists()) return [];
|
||||
|
||||
return dir
|
||||
.listSync()
|
||||
.whereType<File>()
|
||||
.where((f) => f.path.toLowerCase().endsWith('.png'))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,11 +33,11 @@ class TestFlipFlopService {
|
||||
// ThemeService.instance.toggle();
|
||||
|
||||
// Surprise the navigator
|
||||
if (navigatorKey.currentState?.emotion == 'surprise') {
|
||||
navigatorKey.currentState?.reset();
|
||||
} else {
|
||||
navigatorKey.currentState?.setEmotion('surprise');
|
||||
}
|
||||
// if (navigatorKey.currentState?.emotion == 'surprise') {
|
||||
// navigatorKey.currentState?.reset();
|
||||
// } else {
|
||||
// navigatorKey.currentState?.setEmotion('surprise');
|
||||
// }
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,314 +1,351 @@
|
||||
import 'dart:async';
|
||||
import 'package:socket_io_client/socket_io_client.dart' as io;
|
||||
|
||||
import 'backend_service.dart'; // Reuse ArduinoData, GpsData
|
||||
|
||||
/// Connection state for WebSocket
|
||||
enum WsConnectionState {
|
||||
disconnected,
|
||||
connecting,
|
||||
connected,
|
||||
}
|
||||
|
||||
/// Acknowledgment from backend for a command
|
||||
class CommandAck {
|
||||
final String id;
|
||||
final String status;
|
||||
final String? error;
|
||||
final String? extra;
|
||||
|
||||
CommandAck({
|
||||
required this.id,
|
||||
required this.status,
|
||||
this.error,
|
||||
this.extra,
|
||||
});
|
||||
|
||||
bool get isSuccess => status == 'ok' || status == 'sent';
|
||||
}
|
||||
|
||||
/// Alert from backend
|
||||
class BackendAlert {
|
||||
final String type;
|
||||
final String message;
|
||||
|
||||
BackendAlert({required this.type, required this.message});
|
||||
}
|
||||
|
||||
/// Backend status (connection states of GPS/Arduino)
|
||||
class BackendStatus {
|
||||
final bool gpsConnected;
|
||||
final bool arduinoConnected;
|
||||
|
||||
BackendStatus({required this.gpsConnected, required this.arduinoConnected});
|
||||
}
|
||||
|
||||
/// WebSocket service for real-time data from backend.
|
||||
///
|
||||
/// Replaces HTTP polling with push-based updates.
|
||||
/// Maintains dual logical channels:
|
||||
/// - Telemetry: arduino/gps data streams (throttled by backend)
|
||||
/// - Control: button commands and acknowledgments
|
||||
class WebSocketService {
|
||||
WebSocketService._() {
|
||||
_setupStreams();
|
||||
}
|
||||
static final instance = WebSocketService._();
|
||||
|
||||
static const _serverUrl = 'http://127.0.0.1:5000';
|
||||
|
||||
io.Socket? _socket;
|
||||
WsConnectionState _connectionState = WsConnectionState.disconnected;
|
||||
Timer? _reconnectTimer;
|
||||
|
||||
// Latest values for sync access (backward compat)
|
||||
ArduinoData? _latestArduino;
|
||||
GpsData? _latestGps;
|
||||
BackendStatus? _latestStatus;
|
||||
|
||||
// Stream controllers
|
||||
late StreamController<ArduinoData> _arduinoController;
|
||||
late StreamController<GpsData> _gpsController;
|
||||
late StreamController<BackendStatus> _statusController;
|
||||
late StreamController<CommandAck> _ackController;
|
||||
late StreamController<BackendAlert> _alertController;
|
||||
late StreamController<WsConnectionState> _connectionController;
|
||||
late StreamController<String> _debugController;
|
||||
|
||||
// Debug message buffer
|
||||
static const int _maxDebugMessages = 50;
|
||||
final List<String> _debugMessages = [];
|
||||
|
||||
void _setupStreams() {
|
||||
_arduinoController = StreamController<ArduinoData>.broadcast();
|
||||
_gpsController = StreamController<GpsData>.broadcast();
|
||||
_statusController = StreamController<BackendStatus>.broadcast();
|
||||
_ackController = StreamController<CommandAck>.broadcast();
|
||||
_alertController = StreamController<BackendAlert>.broadcast();
|
||||
_connectionController = StreamController<WsConnectionState>.broadcast();
|
||||
_debugController = StreamController<String>.broadcast();
|
||||
}
|
||||
|
||||
/// Log a debug message (adds to buffer and stream)
|
||||
void _log(String message) {
|
||||
_debugMessages.add(message);
|
||||
if (_debugMessages.length > _maxDebugMessages) {
|
||||
_debugMessages.removeAt(0);
|
||||
}
|
||||
_debugController.add(message);
|
||||
}
|
||||
|
||||
// --- Public API: Streams ---
|
||||
|
||||
/// Stream of Arduino telemetry updates
|
||||
Stream<ArduinoData> get arduinoStream => _arduinoController.stream;
|
||||
|
||||
/// Stream of GPS updates
|
||||
Stream<GpsData> get gpsStream => _gpsController.stream;
|
||||
|
||||
/// Stream of backend status updates
|
||||
Stream<BackendStatus> get statusStream => _statusController.stream;
|
||||
|
||||
/// Stream of command acknowledgments
|
||||
Stream<CommandAck> get ackStream => _ackController.stream;
|
||||
|
||||
/// Stream of alerts from backend
|
||||
Stream<BackendAlert> get alertStream => _alertController.stream;
|
||||
|
||||
/// Stream of connection state changes
|
||||
Stream<WsConnectionState> get connectionStream => _connectionController.stream;
|
||||
|
||||
/// Stream of debug log messages
|
||||
Stream<String> get debugStream => _debugController.stream;
|
||||
|
||||
/// Current debug message buffer (for initial display)
|
||||
List<String> get debugMessages => List.unmodifiable(_debugMessages);
|
||||
|
||||
// --- Public API: Sync getters (backward compat) ---
|
||||
|
||||
/// Current connection state
|
||||
WsConnectionState get connectionState => _connectionState;
|
||||
|
||||
/// Whether connected to backend
|
||||
bool get isConnected => _connectionState == WsConnectionState.connected;
|
||||
|
||||
/// Latest Arduino data (may be null if not yet received)
|
||||
ArduinoData? get latestArduino => _latestArduino;
|
||||
|
||||
/// Latest GPS data (may be null if not yet received)
|
||||
GpsData? get latestGps => _latestGps;
|
||||
|
||||
/// Latest backend status
|
||||
BackendStatus? get latestStatus => _latestStatus;
|
||||
|
||||
// --- Public API: Connection ---
|
||||
|
||||
/// Connect to backend WebSocket
|
||||
void connect() {
|
||||
if (_socket != null) return; // Already connected or connecting
|
||||
|
||||
_setConnectionState(WsConnectionState.connecting);
|
||||
|
||||
_socket = io.io(_serverUrl, <String, dynamic>{
|
||||
'transports': ['websocket'],
|
||||
'autoConnect': true,
|
||||
'reconnection': false, // We handle reconnection ourselves
|
||||
});
|
||||
|
||||
_socket!.onConnect((_) {
|
||||
_log('connected');
|
||||
_setConnectionState(WsConnectionState.connected);
|
||||
_cancelReconnect();
|
||||
});
|
||||
|
||||
_socket!.onDisconnect((_) {
|
||||
_log('disconnected');
|
||||
_setConnectionState(WsConnectionState.disconnected);
|
||||
_scheduleReconnect();
|
||||
});
|
||||
|
||||
_socket!.onConnectError((error) {
|
||||
_log('error: $error');
|
||||
_setConnectionState(WsConnectionState.disconnected);
|
||||
_scheduleReconnect();
|
||||
});
|
||||
|
||||
_socket!.onError((error) {
|
||||
_log('error: $error');
|
||||
});
|
||||
|
||||
// --- Telemetry Events ---
|
||||
|
||||
_socket!.on('arduino', (data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
final arduino = ArduinoData.fromJson(data);
|
||||
_latestArduino = arduino;
|
||||
_arduinoController.add(arduino);
|
||||
_log('ard: ${arduino.rpm ?? "-"}rpm ${arduino.voltage ?? "-"}V g${arduino.gear ?? "-"}');
|
||||
}
|
||||
});
|
||||
|
||||
_socket!.on('gps', (data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
final gps = GpsData.fromJson(data);
|
||||
_latestGps = gps;
|
||||
_gpsController.add(gps);
|
||||
_log('gps: ${gps.speed?.toStringAsFixed(1) ?? "-"}m/s mode${gps.mode ?? "-"}');
|
||||
}
|
||||
});
|
||||
|
||||
_socket!.on('status', (data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
final status = BackendStatus(
|
||||
gpsConnected: data['gps_connected'] ?? false,
|
||||
arduinoConnected: data['arduino_connected'] ?? false,
|
||||
);
|
||||
_latestStatus = status;
|
||||
_statusController.add(status);
|
||||
_log('status: gps=${status.gpsConnected} ard=${status.arduinoConnected}');
|
||||
}
|
||||
});
|
||||
|
||||
// --- Control Events ---
|
||||
|
||||
_socket!.on('ack', (data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
final ack = CommandAck(
|
||||
id: data['id'] ?? 'unknown',
|
||||
status: data['status'] ?? 'unknown',
|
||||
error: data['error'],
|
||||
extra: data['extra'],
|
||||
);
|
||||
_ackController.add(ack);
|
||||
_log('ack: ${ack.id}=${ack.status}${ack.error != null ? " err:${ack.error}" : ""}');
|
||||
}
|
||||
});
|
||||
|
||||
_socket!.on('alert', (data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
final alert = BackendAlert(
|
||||
type: data['type'] ?? 'unknown',
|
||||
message: data['message'] ?? '',
|
||||
);
|
||||
_alertController.add(alert);
|
||||
_log('alert: [${alert.type}] ${alert.message}');
|
||||
}
|
||||
});
|
||||
|
||||
_socket!.connect();
|
||||
}
|
||||
|
||||
/// Disconnect from backend
|
||||
void disconnect() {
|
||||
_cancelReconnect();
|
||||
_socket?.disconnect();
|
||||
_socket?.dispose();
|
||||
_socket = null;
|
||||
_setConnectionState(WsConnectionState.disconnected);
|
||||
}
|
||||
|
||||
// --- Public API: Commands ---
|
||||
|
||||
/// Send button event to backend
|
||||
void sendButton(String id, String action, [Map<String, dynamic>? params]) {
|
||||
if (_socket == null || !isConnected) {
|
||||
print('[WS] Cannot send button, not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
final data = <String, dynamic>{
|
||||
'id': id,
|
||||
'action': action,
|
||||
...?params,
|
||||
};
|
||||
|
||||
_socket!.emit('button', data);
|
||||
}
|
||||
|
||||
/// Send emergency signal to backend
|
||||
void sendEmergency(String type) {
|
||||
if (_socket == null) {
|
||||
print('[WS] Cannot send emergency, not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
// Emergency should be sent even if not fully connected
|
||||
_socket!.emit('emergency', {'type': type});
|
||||
}
|
||||
|
||||
// --- Private ---
|
||||
|
||||
void _setConnectionState(WsConnectionState state) {
|
||||
if (_connectionState != state) {
|
||||
_connectionState = state;
|
||||
_connectionController.add(state);
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleReconnect() {
|
||||
_cancelReconnect();
|
||||
_reconnectTimer = Timer(const Duration(seconds: 3), () {
|
||||
print('[WS] Attempting reconnect...');
|
||||
_socket?.dispose();
|
||||
_socket = null;
|
||||
connect();
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelReconnect() {
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = null;
|
||||
}
|
||||
|
||||
/// Dispose all resources (call on app shutdown)
|
||||
void dispose() {
|
||||
disconnect();
|
||||
_arduinoController.close();
|
||||
_gpsController.close();
|
||||
_statusController.close();
|
||||
_ackController.close();
|
||||
_alertController.close();
|
||||
_connectionController.close();
|
||||
_debugController.close();
|
||||
}
|
||||
}
|
||||
import 'dart:async';
|
||||
import 'package:socket_io_client/socket_io_client.dart' as io;
|
||||
|
||||
import 'backend_service.dart'; // Reuse ArduinoData, GpsData
|
||||
import 'theme_service.dart';
|
||||
|
||||
/// Connection state for WebSocket
|
||||
enum WsConnectionState {
|
||||
disconnected,
|
||||
connecting,
|
||||
connected,
|
||||
}
|
||||
|
||||
/// Acknowledgment from backend for a command
|
||||
class CommandAck {
|
||||
final String id;
|
||||
final String status;
|
||||
final String? error;
|
||||
final String? extra;
|
||||
|
||||
CommandAck({
|
||||
required this.id,
|
||||
required this.status,
|
||||
this.error,
|
||||
this.extra,
|
||||
});
|
||||
|
||||
bool get isSuccess => status == 'ok' || status == 'sent';
|
||||
}
|
||||
|
||||
/// Alert from backend
|
||||
class BackendAlert {
|
||||
final String type;
|
||||
final String message;
|
||||
|
||||
BackendAlert({required this.type, required this.message});
|
||||
}
|
||||
|
||||
/// Backend status (connection states of GPS/Arduino)
|
||||
class BackendStatus {
|
||||
final bool gpsConnected;
|
||||
final bool arduinoConnected;
|
||||
|
||||
BackendStatus({required this.gpsConnected, required this.arduinoConnected});
|
||||
}
|
||||
|
||||
/// WebSocket service for real-time data from backend.
|
||||
///
|
||||
/// Replaces HTTP polling with push-based updates.
|
||||
/// Maintains dual logical channels:
|
||||
/// - Telemetry: arduino/gps data streams (throttled by backend)
|
||||
/// - Control: button commands and acknowledgments
|
||||
class WebSocketService {
|
||||
WebSocketService._() {
|
||||
_setupStreams();
|
||||
}
|
||||
static final instance = WebSocketService._();
|
||||
|
||||
static const _serverUrl = 'http://127.0.0.1:5000';
|
||||
|
||||
io.Socket? _socket;
|
||||
WsConnectionState _connectionState = WsConnectionState.disconnected;
|
||||
Timer? _reconnectTimer;
|
||||
|
||||
// Latest values for sync access (backward compat)
|
||||
ArduinoData? _latestArduino;
|
||||
GpsData? _latestGps;
|
||||
LteData? _latestLte;
|
||||
BackendStatus? _latestStatus;
|
||||
|
||||
// Stream controllers
|
||||
late StreamController<ArduinoData> _arduinoController;
|
||||
late StreamController<GpsData> _gpsController;
|
||||
late StreamController<LteData> _lteController;
|
||||
late StreamController<BackendStatus> _statusController;
|
||||
late StreamController<CommandAck> _ackController;
|
||||
late StreamController<BackendAlert> _alertController;
|
||||
late StreamController<WsConnectionState> _connectionController;
|
||||
late StreamController<String> _debugController;
|
||||
|
||||
// Debug message buffer
|
||||
static const int _maxDebugMessages = 50;
|
||||
final List<String> _debugMessages = [];
|
||||
|
||||
void _setupStreams() {
|
||||
_arduinoController = StreamController<ArduinoData>.broadcast();
|
||||
_gpsController = StreamController<GpsData>.broadcast();
|
||||
_lteController = StreamController<LteData>.broadcast();
|
||||
_statusController = StreamController<BackendStatus>.broadcast();
|
||||
_ackController = StreamController<CommandAck>.broadcast();
|
||||
_alertController = StreamController<BackendAlert>.broadcast();
|
||||
_connectionController = StreamController<WsConnectionState>.broadcast();
|
||||
_debugController = StreamController<String>.broadcast();
|
||||
}
|
||||
|
||||
/// Log a debug message (adds to buffer and stream)
|
||||
void _log(String message) {
|
||||
_debugMessages.add(message);
|
||||
if (_debugMessages.length > _maxDebugMessages) {
|
||||
_debugMessages.removeAt(0);
|
||||
}
|
||||
_debugController.add(message);
|
||||
}
|
||||
|
||||
// --- Public API: Streams ---
|
||||
|
||||
/// Stream of Arduino telemetry updates
|
||||
Stream<ArduinoData> get arduinoStream => _arduinoController.stream;
|
||||
|
||||
/// Stream of GPS updates
|
||||
Stream<GpsData> get gpsStream => _gpsController.stream;
|
||||
|
||||
/// Stream of LTE status updates
|
||||
Stream<LteData> get lteStream => _lteController.stream;
|
||||
|
||||
/// Stream of backend status updates
|
||||
Stream<BackendStatus> get statusStream => _statusController.stream;
|
||||
|
||||
/// Stream of command acknowledgments
|
||||
Stream<CommandAck> get ackStream => _ackController.stream;
|
||||
|
||||
/// Stream of alerts from backend
|
||||
Stream<BackendAlert> get alertStream => _alertController.stream;
|
||||
|
||||
/// Stream of connection state changes
|
||||
Stream<WsConnectionState> get connectionStream => _connectionController.stream;
|
||||
|
||||
/// Stream of debug log messages
|
||||
Stream<String> get debugStream => _debugController.stream;
|
||||
|
||||
/// Current debug message buffer (for initial display)
|
||||
List<String> get debugMessages => List.unmodifiable(_debugMessages);
|
||||
|
||||
// --- Public API: Sync getters (backward compat) ---
|
||||
|
||||
/// Current connection state
|
||||
WsConnectionState get connectionState => _connectionState;
|
||||
|
||||
/// Whether connected to backend
|
||||
bool get isConnected => _connectionState == WsConnectionState.connected;
|
||||
|
||||
/// Latest Arduino data (may be null if not yet received)
|
||||
ArduinoData? get latestArduino => _latestArduino;
|
||||
|
||||
/// Latest GPS data (may be null if not yet received)
|
||||
GpsData? get latestGps => _latestGps;
|
||||
|
||||
/// Latest LTE data (may be null if not yet received)
|
||||
LteData? get latestLte => _latestLte;
|
||||
|
||||
/// Latest backend status
|
||||
BackendStatus? get latestStatus => _latestStatus;
|
||||
|
||||
// --- Public API: Connection ---
|
||||
|
||||
/// Connect to backend WebSocket
|
||||
void connect() {
|
||||
if (_socket != null) return; // Already connected or connecting
|
||||
|
||||
_setConnectionState(WsConnectionState.connecting);
|
||||
|
||||
_socket = io.io(_serverUrl, <String, dynamic>{
|
||||
'transports': ['websocket'],
|
||||
'autoConnect': true,
|
||||
'reconnection': false, // We handle reconnection ourselves
|
||||
});
|
||||
|
||||
_socket!.onConnect((_) {
|
||||
_log('connected');
|
||||
_setConnectionState(WsConnectionState.connected);
|
||||
_cancelReconnect();
|
||||
});
|
||||
|
||||
_socket!.onDisconnect((_) {
|
||||
_log('disconnected');
|
||||
_setConnectionState(WsConnectionState.disconnected);
|
||||
_scheduleReconnect();
|
||||
});
|
||||
|
||||
_socket!.onConnectError((error) {
|
||||
_log('error: $error');
|
||||
_setConnectionState(WsConnectionState.disconnected);
|
||||
_scheduleReconnect();
|
||||
});
|
||||
|
||||
_socket!.onError((error) {
|
||||
_log('error: $error');
|
||||
});
|
||||
|
||||
// --- Telemetry Events ---
|
||||
|
||||
_socket!.on('arduino', (data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
final arduino = ArduinoData.fromJson(data);
|
||||
_latestArduino = arduino;
|
||||
_arduinoController.add(arduino);
|
||||
final rollStr = arduino.roll != null ? 'r${arduino.roll!.round()}' : '';
|
||||
final pitchStr = arduino.pitch != null ? 'p${arduino.pitch!.round()}' : '';
|
||||
final imuStr = (rollStr.isNotEmpty || pitchStr.isNotEmpty) ? ' $rollStr$pitchStr' : '';
|
||||
_log('ard: ${arduino.rpm ?? "-"}rpm ${arduino.voltage ?? "-"}V g${arduino.gear ?? "-"}$imuStr');
|
||||
|
||||
// Theme switch piggybacks on arduino packets (edge-triggered from backend)
|
||||
if (data.containsKey('theme_switch')) {
|
||||
final isDark = data['theme_switch'] as bool;
|
||||
ThemeService.instance.setDarkMode(isDark);
|
||||
_log('theme: ${isDark ? "dark" : "light"}');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
_socket!.on('gps', (data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
final gps = GpsData.fromJson(data);
|
||||
_latestGps = gps;
|
||||
_gpsController.add(gps);
|
||||
_log('gps: ${gps.speed?.toStringAsFixed(1) ?? "-"}m/s hdg=${gps.track?.round() ?? "-"}° mode${gps.mode ?? "-"}');
|
||||
}
|
||||
});
|
||||
|
||||
_socket!.on('lte', (data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
final lte = LteData.fromJson(data);
|
||||
_latestLte = lte;
|
||||
_lteController.add(lte);
|
||||
_log('lte: ${lte.signal ?? "-"}% ${lte.operator_ ?? "-"} ${lte.accessTech ?? "-"}');
|
||||
}
|
||||
});
|
||||
|
||||
_socket!.on('status', (data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
final status = BackendStatus(
|
||||
gpsConnected: data['gps_connected'] ?? false,
|
||||
arduinoConnected: data['arduino_connected'] ?? false,
|
||||
);
|
||||
_latestStatus = status;
|
||||
_statusController.add(status);
|
||||
_log('status: gps=${status.gpsConnected} ard=${status.arduinoConnected}');
|
||||
|
||||
// Initial theme state comes with status on connect
|
||||
if (data.containsKey('theme_switch')) {
|
||||
final isDark = data['theme_switch'] as bool;
|
||||
ThemeService.instance.setDarkMode(isDark);
|
||||
_log('theme: ${isDark ? "dark" : "light"} (initial)');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- Control Events ---
|
||||
|
||||
_socket!.on('ack', (data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
final ack = CommandAck(
|
||||
id: data['id'] ?? 'unknown',
|
||||
status: data['status'] ?? 'unknown',
|
||||
error: data['error'],
|
||||
extra: data['extra'],
|
||||
);
|
||||
_ackController.add(ack);
|
||||
_log('ack: ${ack.id}=${ack.status}${ack.error != null ? " err:${ack.error}" : ""}');
|
||||
}
|
||||
});
|
||||
|
||||
_socket!.on('alert', (data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
final alert = BackendAlert(
|
||||
type: data['type'] ?? 'unknown',
|
||||
message: data['message'] ?? '',
|
||||
);
|
||||
_alertController.add(alert);
|
||||
_log('alert: [${alert.type}] ${alert.message}');
|
||||
}
|
||||
});
|
||||
|
||||
_socket!.connect();
|
||||
}
|
||||
|
||||
/// Disconnect from backend
|
||||
void disconnect() {
|
||||
_cancelReconnect();
|
||||
_socket?.disconnect();
|
||||
_socket?.dispose();
|
||||
_socket = null;
|
||||
_setConnectionState(WsConnectionState.disconnected);
|
||||
}
|
||||
|
||||
// --- Public API: Commands ---
|
||||
|
||||
/// Send button event to backend
|
||||
void sendButton(String id, String action, [Map<String, dynamic>? params]) {
|
||||
if (_socket == null || !isConnected) {
|
||||
print('[WS] Cannot send button, not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
final data = <String, dynamic>{
|
||||
'id': id,
|
||||
'action': action,
|
||||
...?params,
|
||||
};
|
||||
|
||||
_socket!.emit('button', data);
|
||||
}
|
||||
|
||||
/// Send emergency signal to backend
|
||||
void sendEmergency(String type) {
|
||||
if (_socket == null) {
|
||||
print('[WS] Cannot send emergency, not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
// Emergency should be sent even if not fully connected
|
||||
_socket!.emit('emergency', {'type': type});
|
||||
}
|
||||
|
||||
// --- Private ---
|
||||
|
||||
void _setConnectionState(WsConnectionState state) {
|
||||
if (_connectionState != state) {
|
||||
_connectionState = state;
|
||||
_connectionController.add(state);
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleReconnect() {
|
||||
_cancelReconnect();
|
||||
_reconnectTimer = Timer(const Duration(seconds: 3), () {
|
||||
print('[WS] Attempting reconnect...');
|
||||
_socket?.dispose();
|
||||
_socket = null;
|
||||
connect();
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelReconnect() {
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = null;
|
||||
}
|
||||
|
||||
/// Dispose all resources (call on app shutdown)
|
||||
void dispose() {
|
||||
disconnect();
|
||||
_arduinoController.close();
|
||||
_gpsController.close();
|
||||
_lteController.close();
|
||||
_statusController.close();
|
||||
_ackController.close();
|
||||
_alertController.close();
|
||||
_connectionController.close();
|
||||
_debugController.close();
|
||||
}
|
||||
}
|
||||
|
||||
333
pi/ui/lib/widgets/accel_graph.dart
Normal file
333
pi/ui/lib/widgets/accel_graph.dart
Normal file
@@ -0,0 +1,333 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
/// 2D lateral G-meter showing acceleration as a dot on a cartesian grid.
|
||||
///
|
||||
/// Visual: square grid with dot position = (ax, ay) scaled by maxG
|
||||
/// Optional ghost dot tracks peak magnitude within ghostTrackPeriod window.
|
||||
class AccelGraph extends StatefulWidget {
|
||||
/// X-axis acceleration in g (lateral: negative = left, positive = right)
|
||||
final double? ax;
|
||||
|
||||
/// Y-axis acceleration in g (longitudinal: negative = back, positive = forward)
|
||||
final double? ay;
|
||||
|
||||
/// Maximum G range for the grid (default 2.0 = ±2G)
|
||||
final double maxG;
|
||||
|
||||
/// If set, shows a ghost dot at peak magnitude position, resetting after this duration
|
||||
final Duration? ghostTrackPeriod;
|
||||
|
||||
const AccelGraph({
|
||||
super.key,
|
||||
this.ax,
|
||||
this.ay,
|
||||
this.maxG = 2.0,
|
||||
this.ghostTrackPeriod,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AccelGraph> createState() => _AccelGraphState();
|
||||
}
|
||||
|
||||
class _AccelGraphState extends State<AccelGraph> {
|
||||
// Ghost dot tracking
|
||||
double _ghostAx = 0;
|
||||
double _ghostAy = 0;
|
||||
double _ghostMagnitude = 0;
|
||||
|
||||
// Timestamped history for sliding window
|
||||
List<({DateTime time, double ax, double ay})> _history = [];
|
||||
|
||||
@override
|
||||
void didUpdateWidget(AccelGraph oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
final currentAx = widget.ax ?? 0;
|
||||
final currentAy = widget.ay ?? 0;
|
||||
final now = DateTime.now();
|
||||
|
||||
// Only track history when ghostTrackPeriod is configured
|
||||
if (widget.ghostTrackPeriod != null) {
|
||||
// Add current reading to history
|
||||
_history.add((time: now, ax: currentAx, ay: currentAy));
|
||||
|
||||
// Prune entries outside the window
|
||||
final cutoff = now.subtract(widget.ghostTrackPeriod!);
|
||||
_history.removeWhere((e) => e.time.isBefore(cutoff));
|
||||
|
||||
// Recalculate ghost as max magnitude from current window
|
||||
_ghostAx = currentAx;
|
||||
_ghostAy = currentAy;
|
||||
_ghostMagnitude = 0;
|
||||
|
||||
for (final entry in _history) {
|
||||
final mag = math.sqrt(entry.ax * entry.ax + entry.ay * entry.ay);
|
||||
if (mag > _ghostMagnitude) {
|
||||
_ghostAx = entry.ax;
|
||||
_ghostAy = entry.ay;
|
||||
_ghostMagnitude = mag;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No window configured - clear history to save memory
|
||||
_history.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppTheme.of(context);
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final size = math.min(constraints.maxWidth, constraints.maxHeight);
|
||||
final gridSize = size * 0.6;
|
||||
final fontSize = size * 0.12;
|
||||
final strokeSize = size * 0.015;
|
||||
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// G-meter grid
|
||||
SizedBox(
|
||||
width: gridSize,
|
||||
height: gridSize,
|
||||
child: CustomPaint(
|
||||
painter: _AccelGraphPainter(
|
||||
ax: widget.ax ?? 0,
|
||||
ay: widget.ay ?? 0,
|
||||
ghostAx: _ghostAx,
|
||||
ghostAy: _ghostAy,
|
||||
showGhost: widget.ghostTrackPeriod != null && _ghostMagnitude > 0,
|
||||
maxG: widget.maxG,
|
||||
foreground: theme.foreground,
|
||||
subdued: theme.subdued,
|
||||
background: theme.background,
|
||||
strokeWeight: strokeSize,
|
||||
traceBuffer: _history.map((e) => Offset(e.ax, e.ay)).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: size * 0.03),
|
||||
|
||||
// Numeric readout
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Lon: ${_formatAccel(widget.ay)} (${_formatAccel(_ghostAy)})',
|
||||
style: TextStyle(
|
||||
fontSize: fontSize * 0.5,
|
||||
fontWeight: FontWeight.w400,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
color: theme.foreground,
|
||||
),
|
||||
),
|
||||
SizedBox(width: size * 0.1),
|
||||
Text(
|
||||
'Lat: ${_formatAccel(widget.ax)} (${_formatAccel(_ghostAx)})',
|
||||
style: TextStyle(
|
||||
fontSize: fontSize * 0.5,
|
||||
fontWeight: FontWeight.w400,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
color: theme.subdued,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Label
|
||||
Text(
|
||||
'Acceleration',
|
||||
style: TextStyle(
|
||||
fontSize: fontSize * 0.8,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: theme.subdued,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _formatAccel(double? force) {
|
||||
if (force == null) return '—°';
|
||||
return '${
|
||||
force.toStringAsFixed(1) == '-0.0' ? '0.0' : force.toStringAsFixed(1)
|
||||
}G';
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom painter for the G-meter grid and dots
|
||||
class _AccelGraphPainter extends CustomPainter {
|
||||
final double ax;
|
||||
final double ay;
|
||||
final double ghostAx;
|
||||
final double ghostAy;
|
||||
final bool showGhost;
|
||||
final double maxG;
|
||||
final Color foreground;
|
||||
final Color subdued;
|
||||
final Color background;
|
||||
final double strokeWeight;
|
||||
final List<Offset> traceBuffer;
|
||||
|
||||
_AccelGraphPainter({
|
||||
required this.ax,
|
||||
required this.ay,
|
||||
required this.ghostAx,
|
||||
required this.ghostAy,
|
||||
required this.showGhost,
|
||||
required this.maxG,
|
||||
required this.foreground,
|
||||
required this.subdued,
|
||||
required this.background,
|
||||
required this.strokeWeight,
|
||||
required this.traceBuffer,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final center = Offset(size.width / 2, size.height / 2);
|
||||
final halfSize = size.width / 2;
|
||||
final radius = math.min(size.width, size.height) / 2;
|
||||
|
||||
canvas.clipPath(Path()..addOval(Rect.fromCircle(center: center, radius: radius)));
|
||||
|
||||
// No rectangular border
|
||||
|
||||
// Grid lines at 0.25G intervals
|
||||
final gridPaint = Paint()
|
||||
..color = subdued
|
||||
..strokeWidth = strokeWeight * 0.4
|
||||
..style = PaintingStyle.stroke;
|
||||
|
||||
final gStep = 0.25;
|
||||
for (double g = gStep; g < maxG; g += gStep) {
|
||||
final offset = (g / maxG) * halfSize;
|
||||
|
||||
// Vertical lines (left and right of center)
|
||||
canvas.drawLine(
|
||||
Offset(center.dx - offset, 0),
|
||||
Offset(center.dx - offset, size.height),
|
||||
gridPaint,
|
||||
);
|
||||
canvas.drawLine(
|
||||
Offset(center.dx + offset, 0),
|
||||
Offset(center.dx + offset, size.height),
|
||||
gridPaint,
|
||||
);
|
||||
|
||||
// Horizontal lines (above and below center)
|
||||
canvas.drawLine(
|
||||
Offset(0, center.dy - offset),
|
||||
Offset(size.width, center.dy - offset),
|
||||
gridPaint,
|
||||
);
|
||||
canvas.drawLine(
|
||||
Offset(0, center.dy + offset),
|
||||
Offset(size.width, center.dy + offset),
|
||||
gridPaint,
|
||||
);
|
||||
}
|
||||
|
||||
// Center axis lines (heavier)
|
||||
final axisPaint = Paint()
|
||||
..color = subdued
|
||||
..strokeWidth = strokeWeight
|
||||
..style = PaintingStyle.stroke;
|
||||
|
||||
// Horizontal axis
|
||||
canvas.drawLine(
|
||||
Offset(0, center.dy),
|
||||
Offset(size.width, center.dy),
|
||||
axisPaint,
|
||||
);
|
||||
// Vertical axis
|
||||
canvas.drawLine(
|
||||
Offset(center.dx, 0),
|
||||
Offset(center.dx, size.height),
|
||||
axisPaint,
|
||||
);
|
||||
|
||||
// G-ring markers (circles at every 0.5G for quick reference)
|
||||
final ringPaint = Paint()
|
||||
..color = subdued
|
||||
..strokeWidth = strokeWeight * 0.5
|
||||
..style = PaintingStyle.stroke;
|
||||
|
||||
for (double g = 0.5; g <= maxG; g += 0.5) {
|
||||
final radius = (g / maxG) * halfSize;
|
||||
canvas.drawCircle(center, radius, ringPaint);
|
||||
}
|
||||
|
||||
// Trace line
|
||||
if (traceBuffer.length >= 2) {
|
||||
final tracePaint = Paint()
|
||||
..color = foreground
|
||||
..strokeWidth = strokeWeight * 0.4
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeCap = StrokeCap.round
|
||||
..strokeJoin = StrokeJoin.round;
|
||||
|
||||
final path = Path();
|
||||
for (int i = 0; i < traceBuffer.length; i++) {
|
||||
final pt = traceBuffer[i];
|
||||
final x = center.dx + (pt.dx.clamp(-maxG, maxG) / maxG) * halfSize;
|
||||
final y = center.dy - (pt.dy.clamp(-maxG, maxG) / maxG) * halfSize;
|
||||
if (i == 0) {
|
||||
path.moveTo(x, y);
|
||||
} else {
|
||||
path.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
canvas.drawPath(path, tracePaint);
|
||||
}
|
||||
|
||||
// Ghost dot (if enabled and has data)
|
||||
if (showGhost) {
|
||||
final ghostX = center.dx + (ghostAx / maxG) * halfSize;
|
||||
final ghostY = center.dy - (ghostAy / maxG) * halfSize; // Y inverted (up = positive)
|
||||
final ghostRadius = halfSize * 0.08;
|
||||
|
||||
final ghostPaint = Paint()
|
||||
..color = subdued
|
||||
..strokeWidth = strokeWeight
|
||||
..style = PaintingStyle.stroke;
|
||||
|
||||
canvas.drawCircle(Offset(ghostX, ghostY), ghostRadius, ghostPaint);
|
||||
}
|
||||
|
||||
// Main dot - clamp to grid bounds
|
||||
final clampedAx = ax.clamp(-maxG, maxG);
|
||||
final clampedAy = ay.clamp(-maxG, maxG);
|
||||
final dotX = center.dx + (clampedAx / maxG) * halfSize;
|
||||
final dotY = center.dy - (clampedAy / maxG) * halfSize; // Y inverted (up = positive)
|
||||
final dotRadius = halfSize * 0.1;
|
||||
|
||||
final dotPaint = Paint()
|
||||
..color = foreground
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
canvas.drawCircle(Offset(dotX, dotY), dotRadius, dotPaint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_AccelGraphPainter oldDelegate) {
|
||||
return ax != oldDelegate.ax ||
|
||||
ay != oldDelegate.ay ||
|
||||
ghostAx != oldDelegate.ghostAx ||
|
||||
ghostAy != oldDelegate.ghostAy ||
|
||||
showGhost != oldDelegate.showGhost ||
|
||||
maxG != oldDelegate.maxG ||
|
||||
foreground != oldDelegate.foreground ||
|
||||
subdued != oldDelegate.subdued ||
|
||||
traceBuffer != oldDelegate.traceBuffer;
|
||||
}
|
||||
}
|
||||
@@ -1,119 +1,119 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
/// Generic debug console that displays streaming log messages.
|
||||
///
|
||||
/// Can be wired to any message source via [messageStream] and [initialMessages].
|
||||
/// Example sources: WebSocketService.debugStream, ArduinoService logs, etc.
|
||||
class DebugConsole extends StatefulWidget {
|
||||
/// Stream of new messages to display
|
||||
final Stream<String> messageStream;
|
||||
|
||||
/// Initial messages to populate (e.g., from a buffer)
|
||||
final List<String> initialMessages;
|
||||
|
||||
/// Maximum lines to display
|
||||
final int maxLines;
|
||||
|
||||
/// Optional title for the console (shown in title bar)
|
||||
final String? title;
|
||||
|
||||
const DebugConsole({
|
||||
super.key,
|
||||
required this.messageStream,
|
||||
this.initialMessages = const [],
|
||||
this.maxLines = 8,
|
||||
this.title,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DebugConsole> createState() => _DebugConsoleState();
|
||||
}
|
||||
|
||||
class _DebugConsoleState extends State<DebugConsole> {
|
||||
final List<String> _messages = [];
|
||||
StreamSubscription<String>? _sub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Initialize with existing buffer
|
||||
_messages.addAll(widget.initialMessages);
|
||||
_trimMessages();
|
||||
|
||||
// Subscribe to new messages
|
||||
_sub = widget.messageStream.listen((msg) {
|
||||
setState(() {
|
||||
_messages.add(msg);
|
||||
_trimMessages();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _trimMessages() {
|
||||
while (_messages.length > widget.maxLines) {
|
||||
_messages.removeAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppTheme.of(context);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.background.withAlpha(64),
|
||||
border: Border.all(color: theme.subdued, width: 2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Title bar (optional)
|
||||
if (widget.title != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: theme.subdued, width: 1),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
widget.title!,
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 24,
|
||||
color: theme.subdued,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Console content
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Text(
|
||||
_messages.isEmpty ? '(no messages)' : _messages.join('\n'),
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 30,
|
||||
color: theme.foreground,
|
||||
height: 1.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
/// Generic debug console that displays streaming log messages.
|
||||
///
|
||||
/// Can be wired to any message source via [messageStream] and [initialMessages].
|
||||
/// Example sources: WebSocketService.debugStream, ArduinoService logs, etc.
|
||||
class DebugConsole extends StatefulWidget {
|
||||
/// Stream of new messages to display
|
||||
final Stream<String> messageStream;
|
||||
|
||||
/// Initial messages to populate (e.g., from a buffer)
|
||||
final List<String> initialMessages;
|
||||
|
||||
/// Maximum lines to display
|
||||
final int maxLines;
|
||||
|
||||
/// Optional title for the console (shown in title bar)
|
||||
final String? title;
|
||||
|
||||
const DebugConsole({
|
||||
super.key,
|
||||
required this.messageStream,
|
||||
this.initialMessages = const [],
|
||||
this.maxLines = 8,
|
||||
this.title,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DebugConsole> createState() => _DebugConsoleState();
|
||||
}
|
||||
|
||||
class _DebugConsoleState extends State<DebugConsole> {
|
||||
final List<String> _messages = [];
|
||||
StreamSubscription<String>? _sub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Initialize with existing buffer
|
||||
_messages.addAll(widget.initialMessages);
|
||||
_trimMessages();
|
||||
|
||||
// Subscribe to new messages
|
||||
_sub = widget.messageStream.listen((msg) {
|
||||
setState(() {
|
||||
_messages.add(msg);
|
||||
_trimMessages();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _trimMessages() {
|
||||
while (_messages.length > widget.maxLines) {
|
||||
_messages.removeAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppTheme.of(context);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.background.withAlpha(64),
|
||||
border: Border.all(color: theme.subdued, width: 2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Title bar (optional)
|
||||
if (widget.title != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: theme.subdued, width: 1),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
widget.title!,
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 24,
|
||||
color: theme.subdued,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Console content
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Text(
|
||||
_messages.isEmpty ? '(no messages)' : _messages.join('\n'),
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 30,
|
||||
color: theme.foreground,
|
||||
height: 1.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
73
pi/ui/lib/widgets/gps_compass.dart
Normal file
73
pi/ui/lib/widgets/gps_compass.dart
Normal file
@@ -0,0 +1,73 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
class GpsCompass extends StatelessWidget {
|
||||
final double? heading;
|
||||
final String? gpsState; // "acquiring", "fix", "lost"
|
||||
|
||||
const GpsCompass({super.key, this.heading, this.gpsState});
|
||||
|
||||
bool get _hasSignal => heading != null;
|
||||
bool get _isAcquiring => gpsState == 'acquiring';
|
||||
|
||||
String get _displayHeading {
|
||||
if (!_hasSignal) return 'N/A';
|
||||
return '${(heading! % 360).round()}';
|
||||
}
|
||||
|
||||
String get _compassDirection {
|
||||
if (!_hasSignal) return '';
|
||||
final directions = [
|
||||
'N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE',
|
||||
'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'
|
||||
];
|
||||
final index = ((heading! % 360) / 22.5).round() % 16;
|
||||
return directions[index];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppTheme.of(context);
|
||||
|
||||
// No signal = subdued color, valid = foreground
|
||||
final iconColour = _hasSignal ? theme.foreground : theme.highlight;
|
||||
|
||||
// Convert to radians, 0 = no rotation when no signal
|
||||
final angle = _hasSignal ? (heading! * math.pi / 180.0) : 0.0;
|
||||
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
flex: 3,
|
||||
child: Transform.rotate(
|
||||
angle: _hasSignal ? angle : 0,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.contain,
|
||||
child: Icon(
|
||||
_hasSignal ? Icons.navigation : Icons.navigation_outlined,
|
||||
size: 120,
|
||||
color: iconColour,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
flex: 1,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.contain,
|
||||
child: Text(
|
||||
_hasSignal ? "${_displayHeading} ${_compassDirection}" : (_isAcquiring ? "ACQ" : "N/A"),
|
||||
style: TextStyle(
|
||||
fontSize: 80,
|
||||
color: theme.subdued, // less emphasis on text, let the icon have semantic colour
|
||||
fontFamily: 'DIN1451',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,59 +1,100 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/config_service.dart';
|
||||
|
||||
/// Displays the navigator character with emotion support.
|
||||
///
|
||||
/// Use a GlobalKey to control emotions from parent:
|
||||
/// ```dart
|
||||
/// final _navigatorKey = GlobalKey<NavigatorWidgetState>();
|
||||
/// NavigatorWidget(key: _navigatorKey)
|
||||
/// // Later:
|
||||
/// _navigatorKey.currentState?.setEmotion('happy');
|
||||
/// ```
|
||||
class NavigatorWidget extends StatefulWidget {
|
||||
const NavigatorWidget({super.key});
|
||||
|
||||
@override
|
||||
State<NavigatorWidget> createState() => NavigatorWidgetState();
|
||||
}
|
||||
|
||||
class NavigatorWidgetState extends State<NavigatorWidget> {
|
||||
String _emotion = 'default';
|
||||
|
||||
/// Change the displayed emotion.
|
||||
/// Image file must exist at: {assetsPath}/navigator/{navigator}/{emotion}.png
|
||||
void setEmotion(String emotion) {
|
||||
if (emotion != _emotion) {
|
||||
setState(() => _emotion = emotion);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset to default emotion
|
||||
void reset() => setEmotion('default');
|
||||
|
||||
/// Current emotion
|
||||
String get emotion => _emotion;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final config = ConfigService.instance;
|
||||
final basePath = '${config.assetsPath}/navigator/${config.navigator}';
|
||||
|
||||
return Image.file(
|
||||
File('$basePath/$_emotion.png'),
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
// Fallback: try default.png if specific emotion missing
|
||||
if (_emotion != 'default') {
|
||||
return Image.file(
|
||||
File('$basePath/default.png'),
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/config_service.dart';
|
||||
|
||||
/// Displays the navigator character with emotion support.
|
||||
///
|
||||
/// Use a GlobalKey to control emotions from parent:
|
||||
/// ```dart
|
||||
/// final _navigatorKey = GlobalKey<NavigatorWidgetState>();
|
||||
/// NavigatorWidget(key: _navigatorKey)
|
||||
/// // Later:
|
||||
/// _navigatorKey.currentState?.setEmotion('happy');
|
||||
/// ```
|
||||
class NavigatorWidget extends StatefulWidget {
|
||||
const NavigatorWidget({super.key});
|
||||
|
||||
@override
|
||||
State<NavigatorWidget> createState() => NavigatorWidgetState();
|
||||
}
|
||||
|
||||
class NavigatorWidgetState extends State<NavigatorWidget>
|
||||
with SingleTickerProviderStateMixin {
|
||||
String _emotion = 'default';
|
||||
late AnimationController _shakeController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_shakeController = AnimationController(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
vsync: this,
|
||||
);
|
||||
// Auto-reset to default after surprise animation completes
|
||||
_shakeController.addStatusListener((status) {
|
||||
if (status == AnimationStatus.completed && _emotion == 'surprise') {
|
||||
setState(() => _emotion = 'default');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_shakeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Change the displayed emotion.
|
||||
/// Image file must exist at: {assetsPath}/navigator/{navigator}/{emotion}.png
|
||||
void setEmotion(String emotion) {
|
||||
if (emotion != _emotion) {
|
||||
setState(() => _emotion = emotion);
|
||||
if (emotion == 'surprise') {
|
||||
_shakeController.forward(from: 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset to default emotion
|
||||
void reset() => setEmotion('default');
|
||||
|
||||
/// Current emotion
|
||||
String get emotion => _emotion;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final config = ConfigService.instance;
|
||||
final basePath = '${config.assetsPath}/navigator/${config.navigator}';
|
||||
|
||||
final image = Image.file(
|
||||
File('$basePath/$_emotion.png'),
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
// Fallback: try default.png if specific emotion missing
|
||||
if (_emotion != 'default') {
|
||||
return Image.file(
|
||||
File('$basePath/default.png'),
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
);
|
||||
|
||||
// Shake animation for surprise
|
||||
return AnimatedBuilder(
|
||||
animation: _shakeController,
|
||||
child: image,
|
||||
builder: (context, child) {
|
||||
final shake = sin(_shakeController.value * pi * 6) * 25 *
|
||||
(1 - _shakeController.value); // 6 oscillations, 25px amplitude, decay
|
||||
return Transform.translate(
|
||||
offset: Offset(shake, 0),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,17 +9,23 @@ class StatBox extends StatelessWidget {
|
||||
final String label;
|
||||
final int flex;
|
||||
|
||||
/// Optional warning predicate - if returns true, value shows in highlight color
|
||||
final bool Function()? isWarning;
|
||||
|
||||
const StatBox({
|
||||
super.key,
|
||||
required this.value,
|
||||
this.unit,
|
||||
required this.label,
|
||||
this.flex = 1,
|
||||
this.isWarning,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppTheme.of(context);
|
||||
final warning = isWarning?.call() ?? false;
|
||||
final valueColor = warning ? theme.highlight : theme.foreground;
|
||||
|
||||
return Expanded(
|
||||
flex: flex,
|
||||
@@ -42,7 +48,7 @@ class StatBox extends StatelessWidget {
|
||||
fontSize: baseSize,
|
||||
fontWeight: FontWeight.w400,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
color: theme.foreground,
|
||||
color: valueColor,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,178 +1,174 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../services/websocket_service.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
/// Android-style persistent status bar for system indicators.
|
||||
/// Shows GPS satellites, LTE signal, Pi temp, voltage, WS status at a glance.
|
||||
class SystemBar extends StatelessWidget {
|
||||
final int? gpsSatellites; // null = disconnected
|
||||
final int? lteSignal; // null = disconnected, 0-4 bars
|
||||
final double? piTemp; // null = unavailable
|
||||
final double? voltage; // null = Arduino disconnected
|
||||
final WsConnectionState? wsState; // WebSocket connection state
|
||||
|
||||
const SystemBar({
|
||||
super.key,
|
||||
this.gpsSatellites,
|
||||
this.lteSignal,
|
||||
this.piTemp,
|
||||
this.voltage,
|
||||
this.wsState,
|
||||
});
|
||||
|
||||
/// Get WebSocket status text and abnormal flag
|
||||
(String, bool) _wsStatus() {
|
||||
switch (wsState) {
|
||||
case WsConnectionState.connected:
|
||||
return ('OK', false);
|
||||
case WsConnectionState.connecting:
|
||||
return ('...', true);
|
||||
case WsConnectionState.disconnected:
|
||||
case null:
|
||||
return ('OFF', true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppTheme.of(context);
|
||||
final (wsText, wsAbnormal) = _wsStatus();
|
||||
|
||||
return Expanded(
|
||||
flex: 1,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Font sizes relative to bar height
|
||||
final labelSize = constraints.maxHeight * 0.5;
|
||||
final valueSize = constraints.maxHeight * 0.5;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: theme.subdued.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Left group: WS, GPS, LTE
|
||||
_Indicator(
|
||||
label: 'WS',
|
||||
value: wsText,
|
||||
isAbnormal: wsAbnormal,
|
||||
alignment: Alignment.centerLeft,
|
||||
labelSize: labelSize,
|
||||
valueSize: valueSize,
|
||||
flex: 2,
|
||||
theme: theme,
|
||||
),
|
||||
_Indicator(
|
||||
label: 'GPS',
|
||||
value: gpsSatellites?.toString() ?? 'N/A',
|
||||
isAbnormal: gpsSatellites == null || gpsSatellites == 0,
|
||||
alignment: Alignment.centerLeft,
|
||||
labelSize: labelSize,
|
||||
valueSize: valueSize,
|
||||
flex: 2,
|
||||
theme: theme,
|
||||
),
|
||||
_Indicator(
|
||||
label: 'LTE',
|
||||
value: lteSignal?.toString() ?? 'N/A',
|
||||
isAbnormal: lteSignal == null,
|
||||
alignment: Alignment.centerLeft,
|
||||
labelSize: labelSize,
|
||||
valueSize: valueSize,
|
||||
flex: 2,
|
||||
theme: theme,
|
||||
),
|
||||
|
||||
// Right group: Pi, Chassis
|
||||
_Indicator(
|
||||
label: 'Pi',
|
||||
value: piTemp != null ? '${piTemp!.toStringAsFixed(1)} °C' : 'N/A',
|
||||
isAbnormal: piTemp == null || piTemp! > 80,
|
||||
alignment: Alignment.centerLeft,
|
||||
labelSize: labelSize,
|
||||
valueSize: valueSize,
|
||||
flex: 2,
|
||||
theme: theme,
|
||||
),
|
||||
_Indicator(
|
||||
label: 'Mains',
|
||||
value: voltage != null ? '${voltage!.toStringAsFixed(1)} V' : 'N/A',
|
||||
isAbnormal: voltage == null || voltage! < 11.9,
|
||||
alignment: Alignment.centerLeft,
|
||||
labelSize: labelSize,
|
||||
valueSize: valueSize,
|
||||
flex: 3,
|
||||
theme: theme,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Single status indicator in a fixed-width flex slot.
|
||||
class _Indicator extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final bool isAbnormal;
|
||||
final Alignment alignment;
|
||||
final double labelSize;
|
||||
final double valueSize;
|
||||
final int flex;
|
||||
final AppTheme theme;
|
||||
|
||||
const _Indicator({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.isAbnormal,
|
||||
required this.alignment,
|
||||
required this.labelSize,
|
||||
required this.valueSize,
|
||||
required this.flex,
|
||||
required this.theme,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
flex: flex,
|
||||
child: Align(
|
||||
alignment: alignment,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
'$label ',
|
||||
style: TextStyle(
|
||||
fontSize: labelSize,
|
||||
color: theme.subdued,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: valueSize,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
color: isAbnormal ? theme.highlight : theme.foreground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../services/websocket_service.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
/// Android-style persistent status bar for system indicators.
|
||||
/// Shows GPS satellites, LTE signal, Pi temp, voltage, WS status at a glance.
|
||||
class SystemBar extends StatelessWidget {
|
||||
final int? gpsSatellites; // null = disconnected
|
||||
final String? gpsState; // "acquiring", "fix", "lost"
|
||||
final int? lteSignal; // null = disconnected, 0-4 bars
|
||||
final double? piTemp; // null = unavailable
|
||||
final double? voltage; // null = Arduino disconnected
|
||||
final WsConnectionState? wsState; // WebSocket connection state
|
||||
|
||||
const SystemBar({
|
||||
super.key,
|
||||
this.gpsSatellites,
|
||||
this.gpsState,
|
||||
this.lteSignal,
|
||||
this.piTemp,
|
||||
this.voltage,
|
||||
this.wsState,
|
||||
});
|
||||
|
||||
/// Get WebSocket status text and abnormal flag
|
||||
(String, bool) _wsStatus() {
|
||||
switch (wsState) {
|
||||
case WsConnectionState.connected:
|
||||
return ('OK', false);
|
||||
case WsConnectionState.connecting:
|
||||
return ('...', true);
|
||||
case WsConnectionState.disconnected:
|
||||
case null:
|
||||
return ('OFF', true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppTheme.of(context);
|
||||
final (wsText, wsAbnormal) = _wsStatus();
|
||||
|
||||
return Expanded(
|
||||
flex: 1,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Font sizes relative to bar height
|
||||
final labelSize = constraints.maxHeight * 0.5;
|
||||
final valueSize = constraints.maxHeight * 0.5;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Left group: WS, GPS, LTE
|
||||
_Indicator(
|
||||
label: 'WS',
|
||||
value: wsText,
|
||||
isAbnormal: wsAbnormal,
|
||||
alignment: Alignment.centerLeft,
|
||||
labelSize: labelSize,
|
||||
valueSize: valueSize,
|
||||
flex: 2,
|
||||
theme: theme,
|
||||
),
|
||||
_Indicator(
|
||||
label: 'GPS',
|
||||
value: gpsState == 'acquiring' ? 'ACQ'
|
||||
: gpsState == 'fix' ? (gpsSatellites?.toString() ?? 'N/A')
|
||||
: '0', // lost or unknown
|
||||
isAbnormal: gpsState != 'fix' || gpsSatellites == null,
|
||||
alignment: Alignment.centerLeft,
|
||||
labelSize: labelSize,
|
||||
valueSize: valueSize,
|
||||
flex: 2,
|
||||
theme: theme,
|
||||
),
|
||||
_Indicator(
|
||||
label: 'LTE',
|
||||
value: lteSignal?.toString() ?? 'N/A',
|
||||
isAbnormal: lteSignal == null,
|
||||
alignment: Alignment.centerLeft,
|
||||
labelSize: labelSize,
|
||||
valueSize: valueSize,
|
||||
flex: 2,
|
||||
theme: theme,
|
||||
),
|
||||
|
||||
// Right group: Pi, Chassis
|
||||
_Indicator(
|
||||
label: 'Pi',
|
||||
value: piTemp != null ? '${piTemp!.toStringAsFixed(1)} °C' : 'N/A',
|
||||
isAbnormal: piTemp == null || piTemp! > 80,
|
||||
alignment: Alignment.centerLeft,
|
||||
labelSize: labelSize,
|
||||
valueSize: valueSize,
|
||||
flex: 2,
|
||||
theme: theme,
|
||||
),
|
||||
_Indicator(
|
||||
label: 'Mains',
|
||||
value: voltage != null ? '${voltage!.toStringAsFixed(1)} V' : 'N/A',
|
||||
isAbnormal: voltage == null || voltage! < 11.7 || voltage! > 14.5,
|
||||
alignment: Alignment.centerLeft,
|
||||
labelSize: labelSize,
|
||||
valueSize: valueSize,
|
||||
flex: 3,
|
||||
theme: theme,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Single status indicator in a fixed-width flex slot.
|
||||
class _Indicator extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final bool isAbnormal;
|
||||
final Alignment alignment;
|
||||
final double labelSize;
|
||||
final double valueSize;
|
||||
final int flex;
|
||||
final AppTheme theme;
|
||||
|
||||
const _Indicator({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.isAbnormal,
|
||||
required this.alignment,
|
||||
required this.labelSize,
|
||||
required this.valueSize,
|
||||
required this.flex,
|
||||
required this.theme,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
flex: flex,
|
||||
child: Align(
|
||||
alignment: alignment,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
'$label ',
|
||||
style: TextStyle(
|
||||
fontSize: labelSize,
|
||||
color: theme.subdued,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: valueSize,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
color: isAbnormal ? theme.highlight : theme.foreground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
269
pi/ui/lib/widgets/whiskey_mark.dart
Normal file
269
pi/ui/lib/widgets/whiskey_mark.dart
Normal file
@@ -0,0 +1,269 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
/// Primitive attitude indicator (whiskey mark) displaying roll/pitch.
|
||||
///
|
||||
/// Visual: tilting horizon line based on roll angle
|
||||
/// Hard left (-45°+): ╲
|
||||
/// Left (-15°): ╲─
|
||||
/// Level (0°): ─
|
||||
/// Right (+15°): ─╱
|
||||
/// Hard right (+45°+): ╱
|
||||
///
|
||||
/// Below the horizon: numeric readout "R: -12° P: 5°"
|
||||
class WhiskeyMark extends StatelessWidget {
|
||||
/// Roll angle in degrees. Negative = left bank, positive = right bank.
|
||||
final double? roll;
|
||||
|
||||
/// Pitch angle in degrees. Negative = nose down, positive = nose up.
|
||||
final double? pitch;
|
||||
|
||||
const WhiskeyMark({
|
||||
super.key,
|
||||
this.roll,
|
||||
this.pitch,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppTheme.of(context);
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final size = math.min(constraints.maxWidth, constraints.maxHeight);
|
||||
final horizonSize = size * 0.6;
|
||||
final fontSize = size * 0.12;
|
||||
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Horizon indicator
|
||||
SizedBox(
|
||||
width: horizonSize,
|
||||
height: horizonSize,
|
||||
child: CustomPaint(
|
||||
painter: _HorizonPainter(
|
||||
roll: roll ?? 0,
|
||||
pitch: pitch ?? 0,
|
||||
lineColor: theme.foreground,
|
||||
borderWeight: 8,
|
||||
skyColor: theme.subdued,
|
||||
groundColor: theme.background,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: size * 0.05),
|
||||
|
||||
// Numeric readout
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Roll: ${_formatAngle(roll)}',
|
||||
style: TextStyle(
|
||||
fontSize: fontSize * 0.5,
|
||||
fontWeight: FontWeight.w400,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
color: theme.foreground,
|
||||
),
|
||||
),
|
||||
SizedBox(width: size * 0.1),
|
||||
Text(
|
||||
'Pitch: ${_formatAngle(pitch)}',
|
||||
style: TextStyle(
|
||||
fontSize: fontSize * 0.5,
|
||||
fontWeight: FontWeight.w400,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
color: theme.subdued,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Label
|
||||
Text(
|
||||
'Attitude',
|
||||
style: TextStyle(
|
||||
fontSize: fontSize * 0.8,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: theme.subdued,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _formatAngle(double? angle) {
|
||||
if (angle == null) return '—°';
|
||||
return '${
|
||||
angle.round() > 180 ? angle.round() - 360 : angle.round()
|
||||
}°';
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom painter for the tilting horizon line
|
||||
class _HorizonPainter extends CustomPainter {
|
||||
final double roll;
|
||||
final double pitch;
|
||||
final double borderWeight;
|
||||
final Color lineColor;
|
||||
final Color skyColor;
|
||||
final Color groundColor;
|
||||
|
||||
_HorizonPainter({
|
||||
required this.roll,
|
||||
required this.pitch,
|
||||
required this.borderWeight,
|
||||
required this.lineColor,
|
||||
required this.skyColor,
|
||||
required this.groundColor,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final center = Offset(size.width / 2, size.height / 2);
|
||||
final radius = math.min(size.width, size.height) / 2;
|
||||
|
||||
// Clip to circle
|
||||
canvas.save();
|
||||
canvas.clipPath(Path()..addOval(Rect.fromCircle(center: center, radius: radius)));
|
||||
|
||||
// Convert roll to radians (negate so positive roll tilts right visually)
|
||||
final rollRad = -roll * math.pi / 180;
|
||||
|
||||
// Pitch offset (positive pitch moves horizon down, showing more sky)
|
||||
// Scale: 90° pitch = full radius displacement
|
||||
final pitchOffset = (pitch / 90) * radius;
|
||||
|
||||
// Calculate horizon line endpoints
|
||||
// The horizon is a horizontal line that we rotate by roll and offset by pitch
|
||||
final horizonY = center.dy + pitchOffset;
|
||||
|
||||
// Paint sky (above horizon)
|
||||
final skyPaint = Paint()..color = skyColor;
|
||||
final groundPaint = Paint()..color = groundColor;
|
||||
|
||||
// Create rotated horizon path
|
||||
canvas.save();
|
||||
canvas.translate(center.dx, center.dy);
|
||||
canvas.rotate(rollRad);
|
||||
canvas.translate(-center.dx, -center.dy);
|
||||
|
||||
// Sky rectangle (above horizon)
|
||||
canvas.drawRect(
|
||||
Rect.fromLTRB(
|
||||
center.dx - radius * 2,
|
||||
center.dy - radius * 2,
|
||||
center.dx + radius * 2,
|
||||
horizonY,
|
||||
),
|
||||
skyPaint,
|
||||
);
|
||||
|
||||
// Ground rectangle (below horizon)
|
||||
canvas.drawRect(
|
||||
Rect.fromLTRB(
|
||||
center.dx - radius * 2,
|
||||
horizonY,
|
||||
center.dx + radius * 2,
|
||||
center.dy + radius * 2,
|
||||
),
|
||||
groundPaint,
|
||||
);
|
||||
|
||||
// Horizon line
|
||||
final linePaint = Paint()
|
||||
..color = lineColor
|
||||
..strokeWidth = borderWeight * 0.1
|
||||
..style = PaintingStyle.stroke;
|
||||
|
||||
canvas.drawLine(
|
||||
Offset(center.dx - radius, horizonY),
|
||||
Offset(center.dx + radius, horizonY),
|
||||
linePaint,
|
||||
);
|
||||
|
||||
// Pitch ladder lines (15° intervals)
|
||||
final ladderPaint = Paint()
|
||||
..color = lineColor
|
||||
..strokeWidth = borderWeight * 0.4
|
||||
..style = PaintingStyle.stroke;
|
||||
|
||||
|
||||
|
||||
for (int deg = -75; deg <= 75; deg += 15) {
|
||||
if (deg == 0) continue; // Skip horizon (already drawn)
|
||||
|
||||
final ladderY = horizonY - (deg / 90) * radius;
|
||||
final double widthMod = (100 - (deg < 0 ? -deg : deg)) / 100;
|
||||
final ladderWidth = radius * 0.7 * widthMod; // longer ladder if close to horizon
|
||||
|
||||
canvas.drawLine(
|
||||
Offset(center.dx - ladderWidth, ladderY),
|
||||
Offset(center.dx + ladderWidth, ladderY),
|
||||
ladderPaint,
|
||||
);
|
||||
}
|
||||
|
||||
canvas.restore();
|
||||
|
||||
// Draw circle border
|
||||
final borderPaint = Paint()
|
||||
..color = lineColor
|
||||
..strokeWidth = borderWeight * 1.1
|
||||
..style = PaintingStyle.stroke;
|
||||
|
||||
canvas.drawCircle(center, radius - 1, borderPaint);
|
||||
|
||||
// Draw center reference mark (fixed, doesn't rotate)
|
||||
final refPaint = Paint()
|
||||
..color = lineColor
|
||||
..strokeWidth = borderWeight
|
||||
..style = PaintingStyle.stroke;
|
||||
|
||||
// Small wings
|
||||
canvas.drawLine(
|
||||
Offset(center.dx - radius * 0.3, center.dy),
|
||||
Offset(center.dx - radius * 0.1, center.dy),
|
||||
refPaint,
|
||||
);
|
||||
canvas.drawLine(
|
||||
Offset(center.dx + radius * 0.1, center.dy),
|
||||
Offset(center.dx + radius * 0.3, center.dy),
|
||||
refPaint,
|
||||
);
|
||||
|
||||
// Center arrow
|
||||
final refTipPaint = Paint()
|
||||
..color = lineColor
|
||||
..strokeWidth = borderWeight * 0.8
|
||||
..style = PaintingStyle.stroke;
|
||||
|
||||
canvas.drawLine(
|
||||
Offset(center.dx, center.dy),
|
||||
Offset(center.dx + radius * 0.07, center.dy + radius * 0.1),
|
||||
refTipPaint,
|
||||
);
|
||||
canvas.drawLine(
|
||||
Offset(center.dx, center.dy),
|
||||
Offset(center.dx - radius * 0.07, center.dy + radius * 0.1),
|
||||
refTipPaint,
|
||||
);
|
||||
|
||||
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_HorizonPainter oldDelegate) {
|
||||
return roll != oldDelegate.roll ||
|
||||
pitch != oldDelegate.pitch ||
|
||||
lineColor != oldDelegate.lineColor;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,26 @@
|
||||
name: smartserow_ui
|
||||
description: Smart Serow embedded UI for Raspberry Pi Zero 2W
|
||||
publish_to: 'none'
|
||||
version: 0.1.0
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
http: ^1.2.0
|
||||
socket_io_client: ^2.0.3+1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^3.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
fonts:
|
||||
- family: DIN1451
|
||||
fonts:
|
||||
- asset: assets/fonts/din1451alt.ttf
|
||||
name: smartserow_ui
|
||||
description: Smart Serow embedded UI for Raspberry Pi Zero 2W
|
||||
publish_to: 'none'
|
||||
version: 0.1.0
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
http: ^1.2.0
|
||||
socket_io_client: ^2.0.3+1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^3.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
||||
fonts:
|
||||
- family: DIN1451
|
||||
fonts:
|
||||
- asset: assets/fonts/din1451alt.ttf
|
||||
|
||||
@@ -46,6 +46,8 @@ Called automatically by `build.py`. Looks for theme matching `navigator` in `con
|
||||
| `pi_setup_backend.sh` | First-time Pi config for backend (uv, gpsd, systemd) |
|
||||
| `smartserow-ui.service.sample` | UI systemd unit template |
|
||||
| `smartserow-backend.service.sample` | Backend systemd unit template |
|
||||
| `smartserow-ui.service` | Production UI systemd unit (gitignored, Pi-specific) |
|
||||
| `smartserow-backend.service` | Production backend systemd unit (gitignored, Pi-specific) |
|
||||
|
||||
```bash
|
||||
# On the Pi - UI setup
|
||||
|
||||
@@ -1,88 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
"""One-click build and deploy for Smart Serow.
|
||||
|
||||
Combines build.py and deploy.py with sensible defaults.
|
||||
Defaults to --restart since that's usually what you want.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Import sibling modules
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from build import build
|
||||
from deploy import deploy
|
||||
from deploy_backend import deploy as deploy_backend
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Build and deploy Smart Serow in one step",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--clean", "-c",
|
||||
action="store_true",
|
||||
help="Clean CMake cache before building",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-restart",
|
||||
action="store_true",
|
||||
help="Don't restart service after deploy (default: restart)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--build-only",
|
||||
action="store_true",
|
||||
help="Only build, don't deploy",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--deploy-only",
|
||||
action="store_true",
|
||||
help="Only deploy, don't build",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ui",
|
||||
action="store_true",
|
||||
help="Build/deploy UI only (no backend)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--backend",
|
||||
action="store_true",
|
||||
help="Deploy backend only (no UI, no build)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Default: both UI and backend if neither flag specified
|
||||
do_ui = args.ui or not args.backend
|
||||
do_backend = args.backend or not args.ui
|
||||
|
||||
restart = not args.no_restart
|
||||
|
||||
# Build UI (only if doing UI and not deploy-only)
|
||||
if do_ui and not args.deploy_only:
|
||||
print()
|
||||
if not build(clean=args.clean):
|
||||
print("UI build failed!")
|
||||
sys.exit(1)
|
||||
|
||||
# Deploy backend FIRST (no build step needed - it's Python)
|
||||
# Backend must be up before UI connects to WebSocket
|
||||
if do_backend and not args.build_only:
|
||||
print()
|
||||
if not deploy_backend(restart=restart):
|
||||
print("Backend deploy failed!")
|
||||
sys.exit(1)
|
||||
|
||||
# Deploy UI after backend is ready
|
||||
if do_ui and not args.build_only:
|
||||
print()
|
||||
if not deploy(restart=restart):
|
||||
print("UI deploy failed!")
|
||||
sys.exit(1)
|
||||
|
||||
print()
|
||||
print("=== All done! ===")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
#!/usr/bin/env python3
|
||||
"""One-click build and deploy for Smart Serow.
|
||||
|
||||
Combines build.py and deploy.py with sensible defaults.
|
||||
Defaults to --restart since that's usually what you want.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Import sibling modules
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from build import build
|
||||
from deploy import deploy
|
||||
from deploy_backend import deploy as deploy_backend
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Build and deploy Smart Serow in one step",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--clean", "-c",
|
||||
action="store_true",
|
||||
help="Clean CMake cache before building",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-restart",
|
||||
action="store_true",
|
||||
help="Don't restart service after deploy (default: restart)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--build-only",
|
||||
action="store_true",
|
||||
help="Only build, don't deploy",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--deploy-only",
|
||||
action="store_true",
|
||||
help="Only deploy, don't build",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ui",
|
||||
action="store_true",
|
||||
help="Build/deploy UI only (no backend)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--backend",
|
||||
action="store_true",
|
||||
help="Deploy backend only (no UI, no build)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Default: both UI and backend if neither flag specified
|
||||
do_ui = args.ui or not args.backend
|
||||
do_backend = args.backend or not args.ui
|
||||
|
||||
restart = not args.no_restart
|
||||
|
||||
# Build UI (only if doing UI and not deploy-only)
|
||||
if do_ui and not args.deploy_only:
|
||||
print()
|
||||
if not build(clean=args.clean):
|
||||
print("UI build failed!")
|
||||
sys.exit(1)
|
||||
|
||||
# Deploy backend FIRST (no build step needed - it's Python)
|
||||
# Backend must be up before UI connects to WebSocket
|
||||
if do_backend and not args.build_only:
|
||||
print()
|
||||
if not deploy_backend(restart=restart):
|
||||
print("Backend deploy failed!")
|
||||
sys.exit(1)
|
||||
|
||||
# Deploy UI after backend is ready
|
||||
if do_ui and not args.build_only:
|
||||
print()
|
||||
if not deploy(restart=restart):
|
||||
print("UI deploy failed!")
|
||||
sys.exit(1)
|
||||
|
||||
print()
|
||||
print("=== All done! ===")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -1,127 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deploy script for Smart Serow Python backend.
|
||||
|
||||
Pushes backend source to Pi and optionally restarts service.
|
||||
Completely independent from UI deploy.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||
PROJECT_ROOT = SCRIPT_DIR.parent
|
||||
CONFIG_FILE = SCRIPT_DIR / "deploy_target.json"
|
||||
BACKEND_DIR = PROJECT_ROOT / "pi" / "backend"
|
||||
|
||||
|
||||
def run(cmd: list[str], check: bool = True, **kwargs) -> subprocess.CompletedProcess:
|
||||
"""Run a command."""
|
||||
print(f" → {' '.join(cmd)}")
|
||||
return subprocess.run(cmd, check=check, **kwargs)
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
"""Load deploy target configuration."""
|
||||
if not CONFIG_FILE.exists():
|
||||
print(f"ERROR: Config file not found: {CONFIG_FILE}")
|
||||
print("Create it based on deploy_target.sample.json")
|
||||
sys.exit(1)
|
||||
|
||||
with open(CONFIG_FILE) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def deploy(restart: bool = False) -> bool:
|
||||
"""Deploy backend to Pi. Returns True on success."""
|
||||
config = load_config()
|
||||
|
||||
pi_user = config["user"]
|
||||
pi_host = config["host"]
|
||||
|
||||
# Backend-specific config (with defaults)
|
||||
remote_path = config.get("backend_path", "/opt/smartserow-backend")
|
||||
service_name = config.get("backend_service", "smartserow-backend")
|
||||
|
||||
ssh_target = f"{pi_user}@{pi_host}"
|
||||
|
||||
print("=== Smart Serow Backend Deploy ===")
|
||||
print(f"Target: {ssh_target}:{remote_path}")
|
||||
print(f"Source: {BACKEND_DIR}")
|
||||
|
||||
if not BACKEND_DIR.exists():
|
||||
print(f"ERROR: Backend directory not found: {BACKEND_DIR}")
|
||||
return False
|
||||
|
||||
# Ensure remote directory exists
|
||||
print()
|
||||
print("Ensuring remote directory...")
|
||||
run(["ssh", ssh_target, f"mkdir -p {remote_path}"])
|
||||
|
||||
# Sync backend source to Pi
|
||||
# Exclude __pycache__, .venv, etc.
|
||||
print()
|
||||
print("Syncing files...")
|
||||
run([
|
||||
"rsync", "-avz", "--delete",
|
||||
"--exclude", "__pycache__",
|
||||
"--exclude", "*.pyc",
|
||||
"--exclude", ".venv",
|
||||
"--exclude", ".ruff_cache",
|
||||
f"{BACKEND_DIR}/",
|
||||
f"{ssh_target}:{remote_path}/",
|
||||
])
|
||||
|
||||
# Run uv sync to install/update dependencies
|
||||
# Use full path since non-interactive SSH doesn't load .bashrc
|
||||
print()
|
||||
print("Running uv sync...")
|
||||
result = run(
|
||||
["ssh", ssh_target, f"cd {remote_path} && ~/.local/bin/uv sync"],
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print("WARNING: uv sync failed - dependencies may be out of date")
|
||||
print("Make sure uv is installed on Pi: curl -LsSf https://astral.sh/uv/install.sh | sh")
|
||||
|
||||
# Restart service if requested
|
||||
if restart:
|
||||
print()
|
||||
print(f"Restarting service: {service_name}")
|
||||
run(["ssh", ssh_target, f"sudo systemctl restart {service_name}"], check=False)
|
||||
time.sleep(2)
|
||||
run(["ssh", ssh_target, f"systemctl status {service_name} --no-pager"], check=False)
|
||||
else:
|
||||
print()
|
||||
print("Deploy complete. To restart service, run:")
|
||||
print(f" ssh {ssh_target} 'sudo systemctl restart {service_name}'")
|
||||
print()
|
||||
print("Or run this script with --restart flag")
|
||||
|
||||
print()
|
||||
print("Note: First-time setup on Pi requires uv to be installed:")
|
||||
print(f" ssh {ssh_target}")
|
||||
print(" curl -LsSf https://astral.sh/uv/install.sh | sh")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Deploy Smart Serow backend to Pi")
|
||||
parser.add_argument(
|
||||
"--restart", "-r",
|
||||
action="store_true",
|
||||
help="Restart the systemd service after deploy",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
success = deploy(restart=args.restart)
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
#!/usr/bin/env python3
|
||||
"""Deploy script for Smart Serow Python backend.
|
||||
|
||||
Pushes backend source to Pi and optionally restarts service.
|
||||
Completely independent from UI deploy.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||
PROJECT_ROOT = SCRIPT_DIR.parent
|
||||
CONFIG_FILE = SCRIPT_DIR / "deploy_target.json"
|
||||
BACKEND_DIR = PROJECT_ROOT / "pi" / "backend"
|
||||
SERVICE_FILE = SCRIPT_DIR / "smartserow-backend.service"
|
||||
|
||||
|
||||
def run(cmd: list[str], check: bool = True, **kwargs) -> subprocess.CompletedProcess:
|
||||
"""Run a command."""
|
||||
print(f" → {' '.join(cmd)}")
|
||||
return subprocess.run(cmd, check=check, **kwargs)
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
"""Load deploy target configuration."""
|
||||
if not CONFIG_FILE.exists():
|
||||
print(f"ERROR: Config file not found: {CONFIG_FILE}")
|
||||
print("Create it based on deploy_target.sample.json")
|
||||
sys.exit(1)
|
||||
|
||||
with open(CONFIG_FILE) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def deploy(restart: bool = False) -> bool:
|
||||
"""Deploy backend to Pi. Returns True on success."""
|
||||
config = load_config()
|
||||
|
||||
pi_user = config["user"]
|
||||
pi_host = config["host"]
|
||||
|
||||
# Backend-specific config (with defaults)
|
||||
remote_path = config.get("backend_path", "/opt/smartserow-backend")
|
||||
service_name = config.get("backend_service", "smartserow-backend")
|
||||
|
||||
ssh_target = f"{pi_user}@{pi_host}"
|
||||
|
||||
print("=== Smart Serow Backend Deploy ===")
|
||||
print(f"Target: {ssh_target}:{remote_path}")
|
||||
print(f"Source: {BACKEND_DIR}")
|
||||
|
||||
if not BACKEND_DIR.exists():
|
||||
print(f"ERROR: Backend directory not found: {BACKEND_DIR}")
|
||||
return False
|
||||
|
||||
# Ensure remote directory exists
|
||||
print()
|
||||
print("Ensuring remote directory...")
|
||||
run(["ssh", ssh_target, f"mkdir -p {remote_path}"])
|
||||
|
||||
# Sync backend source to Pi
|
||||
# Exclude __pycache__, .venv, etc.
|
||||
print()
|
||||
print("Syncing files...")
|
||||
run([
|
||||
"rsync", "-avz", "--delete",
|
||||
"--exclude", "__pycache__",
|
||||
"--exclude", "*.pyc",
|
||||
"--exclude", ".venv",
|
||||
"--exclude", ".ruff_cache",
|
||||
"--exclude", "uv.lock", # Let Pi generate its own lockfile
|
||||
f"{BACKEND_DIR}/",
|
||||
f"{ssh_target}:{remote_path}/",
|
||||
])
|
||||
|
||||
# Ensure system GPIO package is installed (pip version needs compilation)
|
||||
print()
|
||||
print("Ensuring system GPIO package...")
|
||||
run(
|
||||
["ssh", ssh_target, "dpkg -s python3-rpi.gpio >/dev/null 2>&1 || sudo apt install -y python3-rpi.gpio"],
|
||||
check=False,
|
||||
)
|
||||
|
||||
# Create venv with system-site-packages if it doesn't exist
|
||||
# This allows access to apt-installed packages like python3-rpi.gpio
|
||||
print()
|
||||
print("Ensuring venv with system-site-packages...")
|
||||
run(
|
||||
["ssh", ssh_target, f"cd {remote_path} && [ -d .venv ] || ~/.local/bin/uv venv --system-site-packages"],
|
||||
check=False,
|
||||
)
|
||||
|
||||
# Run uv sync to install/update dependencies
|
||||
# Use full path since non-interactive SSH doesn't load .bashrc
|
||||
print()
|
||||
print("Running uv sync...")
|
||||
result = run(
|
||||
["ssh", ssh_target, f"cd {remote_path} && ~/.local/bin/uv sync"],
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print("WARNING: uv sync failed - dependencies may be out of date")
|
||||
print("Make sure uv is installed on Pi: curl -LsSf https://astral.sh/uv/install.sh | sh")
|
||||
|
||||
# Deploy service file if it exists
|
||||
if SERVICE_FILE.exists():
|
||||
print()
|
||||
print("Deploying systemd service file...")
|
||||
run(["scp", str(SERVICE_FILE), f"{ssh_target}:/tmp/"])
|
||||
run([
|
||||
"ssh", ssh_target,
|
||||
f"sudo mv /tmp/{SERVICE_FILE.name} /etc/systemd/system/ && sudo systemctl daemon-reload"
|
||||
])
|
||||
|
||||
# Restart service if requested
|
||||
if restart:
|
||||
print()
|
||||
print(f"Restarting service: {service_name}")
|
||||
run(["ssh", ssh_target, f"sudo systemctl restart {service_name}"], check=False)
|
||||
time.sleep(2)
|
||||
run(["ssh", ssh_target, f"systemctl status {service_name} --no-pager"], check=False)
|
||||
else:
|
||||
print()
|
||||
print("Deploy complete. To restart service, run:")
|
||||
print(f" ssh {ssh_target} 'sudo systemctl restart {service_name}'")
|
||||
print()
|
||||
print("Or run this script with --restart flag")
|
||||
|
||||
print()
|
||||
print("Note: First-time setup on Pi requires:")
|
||||
print(f" ssh {ssh_target}")
|
||||
print(" curl -LsSf https://astral.sh/uv/install.sh | sh # Install uv")
|
||||
print(" sudo apt install python3-rpi.gpio # GPIO support")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Deploy Smart Serow backend to Pi")
|
||||
parser.add_argument(
|
||||
"--restart", "-r",
|
||||
action="store_true",
|
||||
help="Restart the systemd service after deploy",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
success = deploy(restart=args.restart)
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -27,10 +27,10 @@ else
|
||||
echo "uv already installed: $(uv --version)"
|
||||
fi
|
||||
|
||||
# Install gpsd
|
||||
echo "Installing gpsd..."
|
||||
# Install gpsd and GPIO support
|
||||
echo "Installing system packages..."
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gpsd gpsd-clients
|
||||
sudo apt-get install -y gpsd gpsd-clients python3-rpi.gpio
|
||||
|
||||
# Configure gpsd (user needs to edit DEVICES)
|
||||
GPSD_CONFIG="/etc/default/gpsd"
|
||||
@@ -66,7 +66,10 @@ echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Configure gpsd: sudo nano /etc/default/gpsd"
|
||||
echo "2. Deploy backend: python3 scripts/deploy_backend.py (from dev machine)"
|
||||
echo "3. On Pi, install deps: cd $BACKEND_DIR && uv sync"
|
||||
echo "3. On Pi, create venv and install deps:"
|
||||
echo " cd $BACKEND_DIR"
|
||||
echo " uv venv --system-site-packages # Allows access to apt packages"
|
||||
echo " uv sync"
|
||||
echo "4. Start service: sudo systemctl start smartserow-backend"
|
||||
echo ""
|
||||
echo "Useful commands:"
|
||||
|
||||
Reference in New Issue
Block a user