Compare commits

..

32 Commits

Author SHA1 Message Date
Mikkeli Matlock
a46496d688 gps related minor fixes 2026-02-09 17:48:38 +09:00
Mikkeli Matlock
47b3427e63 lte service (backend) and ui handling 2026-02-09 02:36:03 +09:00
Mikkeli Matlock
12a0d58800 switched backend gps service to real mode 2026-02-09 02:35:48 +09:00
629c735eec gps manipulations tailored to sim7600h hat 2026-02-09 02:11:55 +09:00
Mikkeli Matlock
992270ed00 ideas 2026-02-08 03:20:07 +09:00
Mikkeli Matlock
83af09b47c ui: system status bar looks tweak 2026-02-08 03:20:00 +09:00
Mikkeli Matlock
0c342d7989 slight Rei colour theme tweaks 2026-02-08 03:07:09 +09:00
Mikkeli Matlock
58a523aab2 ui: gps compass widget layout changes
- angular direction (0-359) + 16ths compassrose
- documented in ui README
2026-02-08 03:06:52 +09:00
Mikkeli Matlock
896ba322c0 gps: debug stub mode with satellites field and signal loss simulation
- _GPS_DEBUG flag for development without hardware
- stub mode: realistic mock data with occasional signal loss
- satellites field in backend and UI data models
- periodic status logging

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 03:04:44 +09:00
Mikkeli Matlock
9173c3b93a new startup screen logic 2026-02-08 02:56:32 +09:00
Mikkeli Matlock
f2c69587ee ui: gps compass widget visual update 2026-02-08 02:27:21 +09:00
Mikkeli Matlock
324cd5dddc ui: gps compass widget 2026-02-08 00:26:59 +09:00
Mikkeli Matlock
bc53bd7e82 ui: lower surprise threshold (for testing and fun) 2026-02-05 00:57:22 +09:00
Mikkeli Matlock
477fd698dc ui: multiple visual upgrades:
- attitude indicator: pitch ladders and little triangle crosshair
- accelerometer: G trace and various other stuff
- navigator: bigger surprise
2026-02-05 00:55:18 +09:00
Mikkeli Matlock
7301149c47 README updates and minor visual fixes 2026-02-05 00:13:01 +09:00
Mikkeli Matlock
b7cf38c649 ui bump threshold minor change 2026-02-05 00:08:35 +09:00
Mikkeli Matlock
ceb9610bca image preloading during boot 2026-02-05 00:03:10 +09:00
Mikkeli Matlock
7d8f813b59 ui accelerometer compsensations and visual tweaks 2026-02-05 00:00:38 +09:00
Mikkeli Matlock
8044bbde94 pi ui accelerometer widget 2026-02-04 23:19:24 +09:00
Mikkeli Matlock
4a830dde91 pi: GPIO-controlled theme switch
- apt dependencies (RPI.GPIO somehow needs to be installed from apt to work & re-establish uv venv with --system-site-packages
- GPIO 20 triggers mode switching (can link to a photodiode or just switch)
2026-02-04 11:13:07 +09:00
Mikkeli Matlock
64ce2472ab arduino: WT61 quicker init 2026-02-04 09:59:25 +09:00
Mikkeli Matlock
952a42b3e9 ui/backend: theme switch using GPIO 2026-02-03 23:28:54 +09:00
Mikkeli Matlock
5cb0be0aaa ui: changed whiskey mark looks 2026-02-02 19:31:16 +09:00
Mikkeli Matlock
18fbc63281 backend: 20Hz report rate and pitch/roll match 2026-02-02 19:30:55 +09:00
Mikkeli Matlock
83cc6bed19 arduino: WT61 init logics & matched mounting 2026-02-02 19:30:15 +09:00
Mikkeli Matlock
f7f0af92dd flutter: IMU attitude indicator and UART health check
- WhiskeyMark widget shows roll/pitch as horizon line
- ArduinoData model includes IMU euler angles
- startup waits for Arduino via /health endpoint
- config_service exposes backendUrl

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 17:01:45 +09:00
Mikkeli Matlock
c1a2994d00 backend: TSV protocol parsing and IMU roll correction
- parses null-terminated TSV frames per PROTOCOL.md
- periodic status log: fps, voltage, RPM, gear, roll
- roll axis inverted for motorcycle frame alignment
- removed stub mode, relies on reconnect loop

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 17:00:54 +09:00
Mikkeli Matlock
f1ed809c71 arduino: TSV telemetry protocol with mock RPM/gear
- null-terminated TSV frame: V_bat, IMU (9 fields), RPM, gear
- mock RPM ramps 800-8000, gear derived from RPM bands
- voltage calibration offset
- PROTOCOL.md documents wire format

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 17:00:14 +09:00
Mikkeli Matlock
4e68dcef5f arduino: working altsoftserial with WT61 IMU 2026-02-01 12:09:11 +09:00
Mikkeli Matlock
f610f0fed2 Arduino additions 2026-02-01 11:47:37 +09:00
Mikkeli Matlock
559e62e292 Arduino frameworks
- AltSoftSerial UART with WT61 IMU
- UART with Pi on TX0/RX0 (and USB)
2026-02-01 11:47:15 +09:00
Mikkeli Matlock
7a6e69861b backend deployment update and navigator shake animation
- backend: now runs uv sync at service start to make sure of uv lock status. might migrate to package/bundle
- navigator now shakes when entering 'surprise' state
2026-01-30 22:47:18 +09:00
47 changed files with 4566 additions and 2089 deletions

2
.gitignore vendored
View File

@@ -65,6 +65,8 @@ scripts/*.pyo
*.pyc *.pyc
*.pyo *.pyo
__pycache__/ __pycache__/
.venv/
uv.lock
# extra resources # extra resources

5
IDEAS.md Normal file
View 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
View File

@@ -0,0 +1,3 @@
# arduino test files
test/

81
arduino/IMU.md Normal file
View 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
View 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).

View File

@@ -11,26 +11,45 @@ Sensor interface running on Arduino Nano, communicating with Pi via UART.
## Current Capabilities ## Current Capabilities
- Battery voltage monitoring (voltage divider on A0) - 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) Install via Arduino Library Manager:
- Engine temperature (thermocouple/NTC) - **AltSoftSerial** by Paul Stoffregen - for WT61 IMU serial
- Gear position indicator
- Turn signal / high beam status ## 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 ## Hardware
- **MCU**: Arduino Nano (ATmega328P) - **MCU**: Arduino Nano (ATmega328P)
- **Connection**: UART to Pi GPIO (TX→RX, RX→TX, common GND) - **Pi Connection**: UART at 115200 baud (TX→RX, RX→TX, common GND)
- **Voltage sensing**: Resistor divider scaled for 0-20V input range - **IMU**: WT61 module at 9600 baud, 20Hz output
- **Voltage sensing**: Resistor divider (100k/47k) scaled for 0-20V input
## Protocol ## Protocol
Simple text-based for now: TSV (tab-separated), null-terminated frames at 10Hz. See [PROTOCOL.md](PROTOCOL.md) for full specification.
```
V_bat: 12.45V
```
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
View 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
View 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
View 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
View 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
View 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
View 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

View File

@@ -2,24 +2,82 @@
// Motorcycle telemetry hub // Motorcycle telemetry hub
#include "voltage.h" #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() { void setup() {
Serial.begin(9600);
pinMode(LED_BUILTIN, OUTPUT); pinMode(LED_BUILTIN, OUTPUT);
comms_init(); // Hardware Serial first so we can debug
Serial.println(F("[INIT] comms ok"));
voltage_init(); 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() { void loop() {
// Report battery voltage // Always poll IMU - it's streaming at 20Hz
Serial.print("V_bat: "); imu_update();
Serial.print(voltage_read(), 2);
Serial.println("V");
// Heartbeat blink // Update mock RPM (ramping)
digitalWrite(LED_BUILTIN, HIGH); rpm_update();
delay(50);
digitalWrite(LED_BUILTIN, LOW);
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
View 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
View 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

View File

@@ -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 DIVIDER_RATIO = 47.0 / (100.0 + 47.0); // ~0.3197
static const float ADC_REF = 5.0; static const float ADC_REF = 5.0;
static const int ADC_MAX = 1023; 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() { void voltage_init() {
// analogRead doesn't need explicit pinMode, but here for future config voltage_set_smoothing(20); // Default 20 samples
// e.g., could switch to internal 1.1V reference for different range }
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() { int voltage_read_raw() {
return analogRead(PIN_VBAT); return analogRead(PIN_VBAT);
} }
float voltage_read() { int voltage_read_smoothed() {
int raw = voltage_read_raw(); int raw = analogRead(PIN_VBAT);
float vDivider = (raw / (float)ADC_MAX) * ADC_REF;
return vDivider / DIVIDER_RATIO; _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;
} }

View File

@@ -6,10 +6,17 @@
// Initialize voltage monitoring (call in setup) // Initialize voltage monitoring (call in setup)
void voltage_init(); 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(); 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(); int voltage_read_raw();
#endif #endif

View File

@@ -1,14 +1,14 @@
{ {
"dark": { "dark": {
"background": "#404040", "background": "#303030",
"foreground": "#EAEAEA", "foreground": "#EAEAEA",
"highlight": "#FA1504", "highlight": "#FA1504",
"subdued": "#fda052" "subdued": "#fda052"
}, },
"bright": { "bright": {
"background": "#fda052", "background": "#fda052",
"foreground": "#202020", "foreground": "#303030",
"highlight": "#FB2E0A", "highlight": "#df2100",
"subdued": "#EAEAEA" "subdued": "#EAEAEA"
} }
} }

View File

@@ -8,8 +8,12 @@ Python GPS and Arduino telemetry service for Smart Serow. Connects to `gpsd` and
# Install uv if you haven't # Install uv if you haven't
curl -LsSf https://astral.sh/uv/install.sh | sh 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 cd pi/backend
uv venv --system-site-packages
uv sync uv sync
``` ```
@@ -26,6 +30,8 @@ uv run flask --app main run --host 0.0.0.0 --port 5000 --reload
## API ## API
### HTTP Endpoints
| Endpoint | Description | | Endpoint | Description |
|----------|-------------| |----------|-------------|
| `GET /health` | Health check, shows gpsd and Arduino connection status | | `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` | Latest Arduino telemetry (voltage, rpm, eng_temp, gear) |
| `GET /arduino/history` | Last 100 buffered Arduino readings | | `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 ## Test from SSH
```bash ```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 - **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 - **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 ## Deploy

View File

@@ -1,255 +1,308 @@
"""Arduino service - connects to Arduino Nano via serial, buffers telemetry.""" """Arduino service - connects to Arduino Nano via serial, buffers telemetry."""
import json import json
import re import math
import threading import re
import time import threading
from collections import deque import time
from typing import Any from collections import deque
from typing import Any
# pyserial for UART communication
try: # pyserial for UART communication
import serial try:
except ImportError: import serial
serial = None # Allow import without pyserial for testing structure except ImportError:
serial = None # Allow import without pyserial for testing structure
class ArduinoService:
"""Threaded Arduino serial reader with buffering and auto-reconnect.""" class ArduinoService:
"""Threaded Arduino serial reader with buffering and auto-reconnect."""
# Regex patterns for legacy text protocol
PATTERNS = { # TSV field names (order per PROTOCOL.md)
"voltage": re.compile(r"V_bat:\s*(\d+\.?\d*)V?", re.IGNORECASE), TSV_FIELDS = ['voltage', 'ax', 'ay', 'az', 'gx', 'gy', 'gz', 'roll', 'pitch', 'yaw', 'rpm', 'gear']
"rpm": re.compile(r"RPM:\s*(\d+)", re.IGNORECASE),
"eng_temp": re.compile(r"ENG:\s*(\d+)C?", re.IGNORECASE), # Regex patterns for legacy text protocol (backwards compatibility)
"gear": re.compile(r"GEAR:\s*(\d+)", re.IGNORECASE), PATTERNS = {
} "voltage": re.compile(r"V_bat:\s*(\d+\.?\d*)V?", re.IGNORECASE),
"rpm": re.compile(r"RPM:\s*(\d+)", re.IGNORECASE),
# ACK pattern: "ACK:CMD:STATUS" or "ACK:CMD:STATUS:extra" "eng_temp": re.compile(r"ENG:\s*(\d+)C?", re.IGNORECASE),
ACK_PATTERN = re.compile(r"ACK:(\w+):(\w+)(?::(.*))?") "gear": re.compile(r"GEAR:\s*(\d+)", re.IGNORECASE),
}
def __init__(
self, # ACK pattern: "ACK:CMD:STATUS" or "ACK:CMD:STATUS:extra"
port: str = "/dev/ttyUSB0", ACK_PATTERN = re.compile(r"ACK:(\w+):(\w+)(?::(.*))?")
baudrate: int = 115200,
buffer_size: int = 100, def __init__(
): self,
self.port = port port: str = "/dev/serial0",
self.baudrate = baudrate baudrate: int = 115200,
self.buffer_size = buffer_size buffer_size: int = 100,
):
self._buffer: deque[dict[str, Any]] = deque(maxlen=buffer_size) self.port = port
self._latest: dict[str, Any] = {} self.baudrate = baudrate
self._connected = False self.buffer_size = buffer_size
self._running = False
self._thread: threading.Thread | None = None self._buffer: deque[dict[str, Any]] = deque(maxlen=buffer_size)
self._lock = threading.Lock() self._latest: dict[str, Any] = {}
self._connected = False
# Callbacks for push-based updates self._running = False
self._on_data_callback = None self._thread: threading.Thread | None = None
self._on_ack_callback = None self._lock = threading.Lock()
# Serial port handle for sending commands # Callbacks for push-based updates
self._serial: Any = None self._on_data_callback = None
self._serial_lock = threading.Lock() self._on_ack_callback = None
def set_on_data(self, callback): # Serial port handle for sending commands
"""Set callback for new telemetry data. Called with data dict.""" self._serial: Any = None
self._on_data_callback = callback self._serial_lock = threading.Lock()
def set_on_ack(self, callback): # Periodic status logging
"""Set callback for ACK responses. Called with (cmd, status, extra).""" self._last_status_log = 0.0
self._on_ack_callback = callback self._frame_count = 0
def send_command(self, cmd: str, params: dict | None = None) -> bool: def set_on_data(self, callback):
"""Send a command to Arduino via serial. """Set callback for new telemetry data. Called with data dict."""
self._on_data_callback = callback
Format: "CMD:NAME:PARAM1:PARAM2..." followed by newline
def set_on_ack(self, callback):
Args: """Set callback for ACK responses. Called with (cmd, status, extra)."""
cmd: Command name (e.g., "HORN", "LIGHT") self._on_ack_callback = callback
params: Optional parameters dict
def send_command(self, cmd: str, params: dict | None = None) -> bool:
Returns: """Send a command to Arduino via serial.
True if sent successfully, False if serial unavailable
""" Format: "CMD:NAME:PARAM1:PARAM2..." followed by newline
with self._serial_lock:
if self._serial is None or not self._connected: Args:
print(f"[Arduino] Cannot send command, not connected") cmd: Command name (e.g., "HORN", "LIGHT")
return False params: Optional parameters dict
try: Returns:
# Build command string True if sent successfully, False if serial unavailable
parts = ["CMD", cmd.upper()] """
if params: with self._serial_lock:
for key, val in params.items(): if self._serial is None or not self._connected:
parts.append(f"{key}={val}") print(f"[Arduino] Cannot send command, not connected")
line = ":".join(parts) + "\n" return False
self._serial.write(line.encode("utf-8")) try:
self._serial.flush() # Build command string
print(f"[Arduino] Sent: {line.strip()}") parts = ["CMD", cmd.upper()]
return True if params:
except Exception as e: for key, val in params.items():
print(f"[Arduino] Failed to send command: {e}") parts.append(f"{key}={val}")
return False line = ":".join(parts) + "\n"
@property self._serial.write(line.encode("utf-8"))
def connected(self) -> bool: self._serial.flush()
return self._connected print(f"[Arduino] Sent: {line.strip()}")
return True
def get_latest(self) -> dict[str, Any]: except Exception as e:
"""Get most recent telemetry values.""" print(f"[Arduino] Failed to send command: {e}")
with self._lock: return False
return self._latest.copy() if self._latest else {"error": "no data"}
@property
def get_buffer(self) -> list[dict[str, Any]]: def connected(self) -> bool:
"""Get buffered telemetry history.""" return self._connected
with self._lock:
return list(self._buffer) def get_latest(self) -> dict[str, Any]:
"""Get most recent telemetry values."""
def start(self): with self._lock:
"""Start background serial reader thread.""" return self._latest.copy() if self._latest else {"error": "no data"}
if self._running:
return def get_buffer(self) -> list[dict[str, Any]]:
self._running = True """Get buffered telemetry history."""
self._thread = threading.Thread(target=self._reader_loop, daemon=True) with self._lock:
self._thread.start() return list(self._buffer)
def stop(self): def start(self):
"""Stop background reader.""" """Start background serial reader thread."""
self._running = False if self._running:
if self._thread: return
self._thread.join(timeout=2.0) self._running = True
self._thread = threading.Thread(target=self._reader_loop, daemon=True)
def _reader_loop(self): self._thread.start()
"""Main reader loop with reconnection logic."""
while self._running: def stop(self):
try: """Stop background reader."""
self._connect_and_read() self._running = False
except Exception as e: if self._thread:
self._connected = False self._thread.join(timeout=2.0)
print(f"[Arduino] Connection error: {e}, retrying in 5s...")
time.sleep(5) def _reader_loop(self):
"""Main reader loop with reconnection logic."""
def _connect_and_read(self): while self._running:
"""Connect to Arduino serial and read data.""" try:
if serial is None: self._connect_and_read()
# Stub mode - no pyserial installed except Exception as e:
print("[Arduino] pyserial not installed, running in stub mode") self._connected = False
self._stub_mode() print(f"[Arduino] Connection error: {e}, retrying in 5s...")
return time.sleep(5)
try: def _connect_and_read(self):
ser = serial.Serial( """Connect to Arduino serial and read data."""
port=self.port, if serial is None:
baudrate=self.baudrate, print("[Arduino] pyserial not installed, cannot connect")
timeout=1.0, return # Will retry via _reader_loop after 5s
)
except serial.SerialException as e: try:
print(f"[Arduino] Cannot open {self.port}: {e}, falling back to stub mode") ser = serial.Serial(
self._stub_mode() port=self.port,
return baudrate=self.baudrate,
timeout=1.0,
try: )
# Store serial handle for send_command() except serial.SerialException as e:
with self._serial_lock: print(f"[Arduino] Cannot open {self.port}: {e}")
self._serial = ser return # Will retry via _reader_loop after 5s
self._connected = True
print(f"[Arduino] Connected to {self.port} @ {self.baudrate} baud") try:
# Store serial handle for send_command()
while self._running: with self._serial_lock:
try: self._serial = ser
line = ser.readline().decode("utf-8", errors="ignore").strip() self._connected = True
if not line: self._last_status_log = time.time()
continue self._frame_count = 0
print(f"[Arduino] Connected to {self.port} @ {self.baudrate} baud")
# Check for ACK responses first
ack_match = self.ACK_PATTERN.match(line) while self._running:
if ack_match: try:
cmd, status, extra = ack_match.groups() # Read null-terminated line (TSV protocol)
if self._on_ack_callback: line = self._read_null_terminated(ser)
self._on_ack_callback(cmd, status, extra) if not line:
continue continue
data = self._parse_line(line) # Check for ACK responses first (legacy newline-terminated)
if data: ack_match = self.ACK_PATTERN.match(line)
data["time"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) if ack_match:
with self._lock: cmd, status, extra = ack_match.groups()
# Merge new values into latest (preserve old values for partial updates) if self._on_ack_callback:
for key, val in data.items(): self._on_ack_callback(cmd, status, extra)
if val is not None: continue
self._latest[key] = val
self._latest["time"] = data["time"] data = self._parse_line(line)
self._buffer.append(self._latest.copy()) if data:
data["time"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
# Invoke callback with new data with self._lock:
if self._on_data_callback: # Merge new values into latest (preserve old values for partial updates)
self._on_data_callback(self._latest.copy()) for key, val in data.items():
if val is not None and not (isinstance(val, float) and math.isnan(val)):
except serial.SerialException as e: self._latest[key] = val
print(f"[Arduino] Serial error: {e}") self._latest["time"] = data["time"]
break self._buffer.append(self._latest.copy())
finally: # Invoke callback with new data
self._connected = False if self._on_data_callback:
with self._serial_lock: self._on_data_callback(self._latest.copy())
self._serial = None
ser.close() # Periodic status log (every 5s)
self._frame_count += 1
def _parse_line(self, line: str) -> dict[str, Any] | None: now = time.time()
"""Parse a line from Arduino - JSON first, fallback to regex. if now - self._last_status_log >= 5.0:
elapsed = now - self._last_status_log
JSON format: {"v":12.45,"rpm":4500,"eng":85,"gear":3} fps = self._frame_count / elapsed
Legacy text: V_bat: 12.45V v = self._latest.get('voltage', 0)
""" rpm = self._latest.get('rpm', 0)
# Try JSON first (production format) gear = self._latest.get('gear', 0)
try: roll = self._latest.get('roll', 0)
obj = json.loads(line) print(f"[Arduino] {fps:.1f} fps | V={v:.1f} RPM={int(rpm)} G={int(gear)} roll={roll:.1f}°")
return { self._last_status_log = now
"voltage": obj.get("v"), self._frame_count = 0
"rpm": obj.get("rpm"),
"eng_temp": obj.get("eng"), except serial.SerialException as e:
"gear": obj.get("gear"), print(f"[Arduino] Serial error: {e}")
} break
except json.JSONDecodeError:
pass finally:
self._connected = False
# Fallback to regex for legacy text protocol with self._serial_lock:
result = {} self._serial = None
for key, pattern in self.PATTERNS.items(): ser.close()
match = pattern.search(line)
if match: def _read_null_terminated(self, ser) -> str:
val = match.group(1) """Read bytes until null terminator or newline (fallback for legacy)."""
result[key] = float(val) if "." in val else int(val) buf = bytearray()
while self._running:
return result if result else None byte = ser.read(1)
if not byte:
def _stub_mode(self): # Timeout
"""Fake data for testing without Arduino connected.""" if buf:
import random # Return partial buffer if we have data
return buf.decode("utf-8", errors="ignore").strip()
_rpm = 3000 return ""
if byte == b'\x00' or byte == b'\n' or byte == b'\r':
while self._running: # End of frame
self._connected = True if buf:
data = { return buf.decode("utf-8", errors="ignore").strip()
"time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), # Skip empty lines / consecutive terminators
"voltage": round(12.0 + random.uniform(-0.5, 0.8), 2), continue
"rpm": _rpm if random.random() > 0.1 else None, buf.append(byte[0])
"eng_temp": random.randint(60, 95), # Safety limit
"gear": random.randint(1, 6) if random.random() > 0.2 else 0, # 0 = neutral if len(buf) > 256:
} return buf.decode("utf-8", errors="ignore").strip()
_rpm += 10
if _rpm > 7500: def _parse_line(self, line: str) -> dict[str, Any] | None:
_rpm = 500 """Parse a line from Arduino - TSV first, then JSON, fallback to regex.
with self._lock: TSV format: 12.45\t0.02\t-0.01\t... (10 fields, per PROTOCOL.md)
self._latest = data JSON format: {"v":12.45,"rpm":4500,"eng":85,"gear":3}
self._buffer.append(data) Legacy text: V_bat: 12.45V
"""
# Invoke callback with new data # Try TSV first (new protocol)
if self._on_data_callback: if '\t' in line:
self._on_data_callback(data) return self._parse_tsv(line)
time.sleep(0.5) # 2Hz stub updates # 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
View 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

View File

@@ -1,143 +1,315 @@
"""GPS service - connects to gpsd, buffers data, handles reconnection.""" """GPS service - connects to gpsd, buffers data, handles reconnection."""
import threading import random
import time import threading
from collections import deque import time
from typing import Any 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) # DEBUG MODE - Set True for development without GPS hardware
try: # When True: skips gpsd entirely, generates realistic mock data
from gpsdclient import GPSDClient # When False: connects to real gpsd (requires GPS device)
except ImportError: # ============================================================================
GPSDClient = None # Allow import without gpsd for testing structure _GPS_DEBUG = False
# gpsdclient is a modern, simple gpsd client
class GPSService: # Install gpsd on Pi: sudo apt install gpsd gpsd-clients
"""Threaded GPS reader with buffering and auto-reconnect.""" # Configure: sudo nano /etc/default/gpsd (set DEVICES="/dev/ttyUSB0" or similar)
try:
def __init__(self, host: str = "127.0.0.1", port: int = 2947, buffer_size: int = 100): from gpsdclient import GPSDClient
self.host = host except ImportError:
self.port = port GPSDClient = None # Allow import without gpsd for testing structure
self.buffer_size = buffer_size
self._buffer: deque[dict[str, Any]] = deque(maxlen=buffer_size) class GPSService:
self._latest: dict[str, Any] = {} """Threaded GPS reader with buffering and auto-reconnect."""
self._connected = False
self._running = False def __init__(self, host: str = "127.0.0.1", port: int = 2947, buffer_size: int = 100):
self._thread: threading.Thread | None = None self.host = host
self._lock = threading.Lock() self.port = port
self.buffer_size = buffer_size
# Callback for push-based updates
self._on_data_callback = None self._buffer: deque[dict[str, Any]] = deque(maxlen=buffer_size)
self._latest: dict[str, Any] = {}
def set_on_data(self, callback): self._connected = False
"""Set callback for new GPS fix. Called with fix dict.""" self._running = False
self._on_data_callback = callback self._thread: threading.Thread | None = None
self._lock = threading.Lock()
@property
def connected(self) -> bool: # Callback for push-based updates
return self._connected self._on_data_callback = None
def get_latest(self) -> dict[str, Any]: # GPS state tracking (NMEA can't distinguish "acquiring" from "lost")
"""Get most recent GPS fix.""" self._has_ever_fixed = False # True after first valid fix this session
with self._lock:
return self._latest.copy() if self._latest else {"error": "no data"} # Periodic status logging
self._last_status_log = 0.0
def get_buffer(self) -> list[dict[str, Any]]: self._last_state_emit = 0.0
"""Get buffered GPS history.""" self._fix_count = 0
with self._lock:
return list(self._buffer) def set_on_data(self, callback):
"""Set callback for new GPS fix. Called with fix dict."""
def start(self): self._on_data_callback = callback
"""Start background GPS reader thread."""
if self._running: @property
return def connected(self) -> bool:
self._running = True return self._connected
self._thread = threading.Thread(target=self._reader_loop, daemon=True)
self._thread.start() def get_latest(self) -> dict[str, Any]:
"""Get most recent GPS fix."""
def stop(self): with self._lock:
"""Stop background reader.""" return self._latest.copy() if self._latest else {"error": "no data"}
self._running = False
if self._thread: def _gps_state(self, fix: dict) -> str:
self._thread.join(timeout=2.0) """Determine GPS state: acquiring, fix, or lost.
def _reader_loop(self): NMEA doesn't distinguish 'never had fix' from 'lost signal' — both
"""Main reader loop with reconnection logic.""" report mode 1 with no position. We track it ourselves.
while self._running: """
try: has_fix = fix.get("mode") in (2, 3) and fix.get("lat") is not None
self._connect_and_read() if has_fix:
except Exception as e: return "fix"
self._connected = False return "lost" if self._has_ever_fixed else "acquiring"
print(f"[GPS] Connection error: {e}, retrying in 5s...")
time.sleep(5) def get_buffer(self) -> list[dict[str, Any]]:
"""Get buffered GPS history."""
def _connect_and_read(self): with self._lock:
"""Connect to gpsd and read data.""" return list(self._buffer)
if GPSDClient is None:
# Stub mode - no gpsd client installed def start(self):
print("[GPS] gpsdclient not installed, running in stub mode") """Start background GPS reader thread."""
self._stub_mode() if self._running:
return return
self._running = True
try: self._thread = threading.Thread(target=self._reader_loop, daemon=True)
client = GPSDClient(host=self.host, port=self.port) self._thread.start()
except Exception as e: print("[GPS] Service started")
print(f"[GPS] Cannot connect to gpsd at {self.host}:{self.port}: {e}, falling back to stub mode")
self._stub_mode() def stop(self):
return """Stop background reader."""
self._running = False
with client: if self._thread:
self._connected = True self._thread.join(timeout=2.0)
print(f"[GPS] Connected to gpsd at {self.host}:{self.port}")
def _reader_loop(self):
for result in client.dict_stream(filter=["TPV"]): """Main reader loop with reconnection logic."""
if not self._running: print("[GPS] Reader thread running")
break while self._running:
try:
# TPV = Time-Position-Velocity report self._connect_and_read()
fix = { except Exception as e:
"time": result.get("time"), self._connected = False
"lat": result.get("lat"), print(f"[GPS] Connection error: {e}, retrying in 5s...")
"lon": result.get("lon"), time.sleep(5)
"alt": result.get("alt"),
"speed": result.get("speed"), # m/s def _connect_and_read(self):
"track": result.get("track"), # heading in degrees """Connect to gpsd and read data."""
"mode": result.get("mode"), # 0=no fix, 2=2D, 3=3D # Debug mode: skip gpsd entirely, use stub data
} if _GPS_DEBUG:
print("[GPS] Debug mode enabled, using stub data")
with self._lock: self._stub_mode()
self._latest = fix return
if fix.get("lat") is not None:
self._buffer.append(fix) if GPSDClient is None:
print("[GPS] gpsdclient not installed, running in stub mode")
# Invoke callback with new fix self._stub_mode()
if self._on_data_callback: return
self._on_data_callback(fix)
# Quick check if gpsd is reachable before attempting connection
def _stub_mode(self): import socket
"""Fake data for testing without gpsd.""" try:
import random sock = socket.create_connection((self.host, self.port), timeout=2.0)
sock.close()
while self._running: except (socket.timeout, socket.error, OSError) as e:
self._connected = True print(f"[GPS] gpsd not reachable at {self.host}:{self.port}: {e}")
fix = { raise ConnectionError(f"gpsd not reachable: {e}")
"time": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"lat": 35.6762 + random.uniform(-0.001, 0.001), try:
"lon": 139.6503 + random.uniform(-0.001, 0.001), client = GPSDClient(host=self.host, port=self.port)
"alt": 40.0 + random.uniform(-5, 5), except Exception as e:
"speed": random.uniform(0, 30), print(f"[GPS] Cannot connect to gpsd at {self.host}:{self.port}: {e}")
"track": random.uniform(0, 360), raise ConnectionError(f"gpsd connection failed: {e}")
"mode": 3,
} with client:
with self._lock: self._connected = True
self._latest = fix print(f"[GPS] Connected to gpsd at {self.host}:{self.port}")
self._buffer.append(fix)
self._last_status_log = time.time()
# Invoke callback with new fix self._fix_count = 0
if self._on_data_callback: # 120s for initial cold fix, 10s for signal loss after first fix
self._on_data_callback(fix) fix_timeout = time.time() + 120.0
time.sleep(1) 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
View 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",
}

View File

@@ -1,236 +1,281 @@
"""Smart Serow Backend - GPS and Arduino services with HTTP API and WebSocket.""" """Smart Serow Backend - GPS and Arduino services with HTTP API and WebSocket."""
from gevent import monkey from gevent import monkey
monkey.patch_all() # Must be at the very top before other imports monkey.patch_all() # Must be at the very top before other imports
from flask import Flask, jsonify from flask import Flask, jsonify
from flask_socketio import SocketIO, emit from flask_socketio import SocketIO, emit
from gps_service import GPSService from gps_service import GPSService
from arduino_service import ArduinoService from arduino_service import ArduinoService
from throttle import Throttle from gpio_service import GPIOService
from lte_service import LteService
app = Flask(__name__) from throttle import Throttle
app.config["SECRET_KEY"] = "smartserow-secret" # Not security critical, just for session
app = Flask(__name__)
# SocketIO with gevent async mode (eventlet is deprecated) app.config["SECRET_KEY"] = "smartserow-secret" # Not security critical, just for session
socketio = SocketIO(app, async_mode="gevent", cors_allowed_origins="*")
# SocketIO with gevent async mode (eventlet is deprecated)
# Services socketio = SocketIO(app, async_mode="gevent", cors_allowed_origins="*")
gps = GPSService()
arduino = ArduinoService() # Services
gps = GPSService()
# Throttles for emission rate limiting (2Hz for arduino, 1Hz for GPS) arduino = ArduinoService()
arduino_throttle = Throttle(min_interval=0.5) # 2Hz max gpio = GPIOService()
gps_throttle = Throttle(min_interval=1.0) # 1Hz max lte = LteService()
# Track connected clients # Throttles for emission rate limiting (20Hz for arduino, 1Hz for GPS, 5s for LTE)
connected_clients = set() 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
# -----------------------------------------------------------------------------
# WebSocket Event Handlers # Track connected clients
# ----------------------------------------------------------------------------- connected_clients = set()
@socketio.on("connect")
def handle_connect(): # -----------------------------------------------------------------------------
"""Client connected.""" # WebSocket Event Handlers
client_id = id(socketio) # Simple identifier # -----------------------------------------------------------------------------
connected_clients.add(client_id)
print(f"[WS] Client connected ({len(connected_clients)} total)") @socketio.on("connect")
def handle_connect():
# Send current status immediately """Client connected."""
emit("status", { client_id = id(socketio) # Simple identifier
"gps_connected": gps.connected, connected_clients.add(client_id)
"arduino_connected": arduino.connected, print(f"[WS] Client connected ({len(connected_clients)} total)")
})
# Send current status immediately
# Send latest data if available emit("status", {
arduino_data = arduino.get_latest() "gps_connected": gps.connected,
if "error" not in arduino_data: "arduino_connected": arduino.connected,
emit("arduino", arduino_data) "theme_switch": gpio.theme_switch,
})
gps_data = gps.get_latest()
if "error" not in gps_data: # Send latest data if available
emit("gps", gps_data) arduino_data = arduino.get_latest()
if "error" not in arduino_data:
emit("arduino", arduino_data)
@socketio.on("disconnect")
def handle_disconnect(): gps_data = gps.get_latest()
"""Client disconnected.""" if "error" not in gps_data:
client_id = id(socketio) emit("gps", gps_data)
connected_clients.discard(client_id)
print(f"[WS] Client disconnected ({len(connected_clients)} remaining)") lte_data = lte.get_latest()
if "error" not in lte_data:
emit("lte", lte_data)
@socketio.on("button")
def handle_button(data):
"""Handle button press from UI. @socketio.on("disconnect")
def handle_disconnect():
Expected data: {"id": "horn", "action": "press", ...params} """Client disconnected."""
""" client_id = id(socketio)
btn_id = data.get("id", "unknown") connected_clients.discard(client_id)
action = data.get("action", "press") print(f"[WS] Client disconnected ({len(connected_clients)} remaining)")
params = {k: v for k, v in data.items() if k not in ("id", "action")}
print(f"[WS] Button: {btn_id} {action} {params}") @socketio.on("button")
def handle_button(data):
# Map button ID to Arduino command """Handle button press from UI.
cmd_map = {
"horn": "HORN", Expected data: {"id": "horn", "action": "press", ...params}
"light": "LIGHT", """
"indicator_left": "IND_L", btn_id = data.get("id", "unknown")
"indicator_right": "IND_R", action = data.get("action", "press")
"hazard": "HAZARD", params = {k: v for k, v in data.items() if k not in ("id", "action")}
}
print(f"[WS] Button: {btn_id} {action} {params}")
cmd = cmd_map.get(btn_id)
if cmd: # Map button ID to Arduino command
# Add action to params (e.g., ON/OFF based on press/release) cmd_map = {
params["state"] = "ON" if action == "press" else "OFF" "horn": "HORN",
success = arduino.send_command(cmd, params) "light": "LIGHT",
"indicator_left": "IND_L",
# Send immediate ack for the attempt "indicator_right": "IND_R",
emit("ack", { "hazard": "HAZARD",
"id": btn_id, }
"status": "sent" if success else "failed",
"error": None if success else "arduino not connected", cmd = cmd_map.get(btn_id)
}) if cmd:
else: # Add action to params (e.g., ON/OFF based on press/release)
emit("ack", { params["state"] = "ON" if action == "press" else "OFF"
"id": btn_id, success = arduino.send_command(cmd, params)
"status": "error",
"error": f"unknown button: {btn_id}", # Send immediate ack for the attempt
}) emit("ack", {
"id": btn_id,
"status": "sent" if success else "failed",
@socketio.on("emergency") "error": None if success else "arduino not connected",
def handle_emergency(data): })
"""Handle emergency signal from UI.""" else:
etype = data.get("type", "stop") emit("ack", {
print(f"[WS] EMERGENCY: {etype}") "id": btn_id,
"status": "error",
# Send emergency command to Arduino "error": f"unknown button: {btn_id}",
arduino.send_command("EMERGENCY", {"type": etype}) })
# Broadcast alert to all clients
socketio.emit("alert", { @socketio.on("emergency")
"type": "emergency", def handle_emergency(data):
"message": f"Emergency {etype} triggered", """Handle emergency signal from UI."""
}) etype = data.get("type", "stop")
print(f"[WS] EMERGENCY: {etype}")
# ----------------------------------------------------------------------------- # Send emergency command to Arduino
# Service Callbacks (push data to WebSocket) arduino.send_command("EMERGENCY", {"type": etype})
# -----------------------------------------------------------------------------
# Broadcast alert to all clients
def on_arduino_data(data): socketio.emit("alert", {
"""Called by ArduinoService when new telemetry arrives.""" "type": "emergency",
def emit_fn(d): "message": f"Emergency {etype} triggered",
socketio.emit("arduino", d) })
arduino_throttle.maybe_emit(data, emit_fn)
# -----------------------------------------------------------------------------
# Service Callbacks (push data to WebSocket)
def on_gps_data(data): # -----------------------------------------------------------------------------
"""Called by GPSService when new fix arrives."""
def emit_fn(d): def on_arduino_data(data):
socketio.emit("gps", d) """Called by ArduinoService when new telemetry arrives."""
# Always include current GPIO state (UI dedupes)
gps_throttle.maybe_emit(data, emit_fn) data = dict(data) # Don't mutate original
data["theme_switch"] = gpio.theme_switch
def on_arduino_ack(cmd, status, extra): # backend voltage offset correction
"""Called by ArduinoService when ACK received from Arduino.""" if "voltage" in data:
socketio.emit("ack", { data["voltage"] += 0.2 # Calibration offset
"id": cmd.lower(),
"status": status.lower(), def emit_fn(d):
"extra": extra, socketio.emit("arduino", d)
})
arduino_throttle.maybe_emit(data, emit_fn)
# -----------------------------------------------------------------------------
# Background task to flush pending throttled data def on_gps_data(data):
# ----------------------------------------------------------------------------- """Called by GPSService when new fix arrives."""
def emit_fn(d):
def throttle_flusher(): socketio.emit("gps", d)
"""Periodically flush pending throttled data."""
import gevent gps_throttle.maybe_emit(data, emit_fn)
while True:
gevent.sleep(0.5)
def on_lte_data(data):
if arduino_throttle.has_pending: """Called by LteService when new status polled."""
arduino_throttle.flush(lambda d: socketio.emit("arduino", d)) def emit_fn(d):
socketio.emit("lte", d)
if gps_throttle.has_pending:
gps_throttle.flush(lambda d: socketio.emit("gps", d)) lte_throttle.maybe_emit(data, emit_fn)
# ----------------------------------------------------------------------------- def on_arduino_ack(cmd, status, extra):
# REST API (backward compatibility) """Called by ArduinoService when ACK received from Arduino."""
# ----------------------------------------------------------------------------- socketio.emit("ack", {
"id": cmd.lower(),
@app.route("/health") "status": status.lower(),
def health(): "extra": extra,
"""Health check endpoint.""" })
return jsonify({
"status": "ok",
"gps_connected": gps.connected, # -----------------------------------------------------------------------------
"arduino_connected": arduino.connected, # Background task to flush pending throttled data
"ws_clients": len(connected_clients), # -----------------------------------------------------------------------------
})
def throttle_flusher():
"""Periodically flush pending throttled data."""
@app.route("/gps") import gevent
def gps_data(): while True:
"""Current GPS data.""" gevent.sleep(0.05) # 20Hz flush rate
return jsonify(gps.get_latest())
if arduino_throttle.has_pending:
arduino_throttle.flush(lambda d: socketio.emit("arduino", d))
@app.route("/gps/history")
def gps_history(): if gps_throttle.has_pending:
"""Buffered GPS history.""" gps_throttle.flush(lambda d: socketio.emit("gps", d))
return jsonify(gps.get_buffer())
if lte_throttle.has_pending:
lte_throttle.flush(lambda d: socketio.emit("lte", d))
@app.route("/arduino")
def arduino_data():
"""Current Arduino telemetry (voltage, rpm, etc).""" # -----------------------------------------------------------------------------
return jsonify(arduino.get_latest()) # REST API (backward compatibility)
# -----------------------------------------------------------------------------
@app.route("/arduino/history") @app.route("/health")
def arduino_history(): def health():
"""Buffered Arduino telemetry history.""" """Health check endpoint."""
return jsonify(arduino.get_buffer()) gps_latest = gps.get_latest()
lte_latest = lte.get_latest()
return jsonify({
# ----------------------------------------------------------------------------- "status": "ok",
# Main Entry Point "gps_connected": gps.connected,
# ----------------------------------------------------------------------------- "gps_state": gps_latest.get("gps_state", "acquiring"),
"arduino_connected": arduino.connected,
def main(): "lte_connected": lte.connected,
"""Entry point.""" "lte_signal": lte_latest.get("signal"),
# Wire up callbacks "ws_clients": len(connected_clients),
arduino.set_on_data(on_arduino_data) })
arduino.set_on_ack(on_arduino_ack)
gps.set_on_data(on_gps_data)
@app.route("/gps")
# Start services def gps_data():
gps.start() """Current GPS data."""
arduino.start() return jsonify(gps.get_latest())
# Start throttle flusher in background
socketio.start_background_task(throttle_flusher) @app.route("/gps/history")
def gps_history():
try: """Buffered GPS history."""
# Use socketio.run() instead of app.run() for WebSocket support return jsonify(gps.get_buffer())
print("[Backend] Starting on http://0.0.0.0:5000")
socketio.run(app, host="0.0.0.0", port=5000, debug=False)
finally: @app.route("/lte")
arduino.stop() def lte_data():
gps.stop() """Current LTE modem status."""
return jsonify(lte.get_latest())
if __name__ == "__main__":
main() @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()

View File

@@ -1,24 +1,26 @@
[project] [project]
name = "smartserow-backend" name = "smartserow-backend"
version = "0.1.0" version = "0.1.0"
description = "GPS and Arduino telemetry service for Smart Serow" description = "GPS and Arduino telemetry service for Smart Serow"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
"flask>=3.0", "flask>=3.0",
"flask-socketio>=5.3.0", "flask-socketio>=5.3.0",
"gevent>=24.0", "gevent>=24.0",
"gevent-websocket>=0.10", "gevent-websocket>=0.10",
"gpsdclient>=1.3", "gpsdclient>=1.3",
"pyserial>=3.5", "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.optional-dependencies]
] dev = [
"ruff",
[project.scripts] ]
smartserow-backend = "main:main"
[project.scripts]
[tool.ruff] smartserow-backend = "main:main"
line-length = 100
[tool.ruff]
line-length = 100

View File

@@ -1,61 +1,61 @@
"""Throttle layer for rate-limiting telemetry emissions.""" """Throttle layer for rate-limiting telemetry emissions."""
import time import time
from typing import Any, Callable from typing import Any, Callable
class Throttle: class Throttle:
"""Rate limiter for WebSocket emissions. """Rate limiter for WebSocket emissions.
Coalesces rapid updates - only emits at most once per min_interval. Coalesces rapid updates - only emits at most once per min_interval.
If multiple updates arrive within the interval, the latest value wins. If multiple updates arrive within the interval, the latest value wins.
""" """
def __init__(self, min_interval: float = 0.5): def __init__(self, min_interval: float = 0.5):
""" """
Args: Args:
min_interval: Minimum seconds between emissions (default 0.5 = 2Hz max) min_interval: Minimum seconds between emissions (default 0.5 = 2Hz max)
""" """
self._last_emit: float = 0 self._last_emit: float = 0
self._min_interval = min_interval self._min_interval = min_interval
self._pending: Any = None self._pending: Any = None
def maybe_emit(self, data: Any, emit_fn: Callable[[Any], None]) -> bool: def maybe_emit(self, data: Any, emit_fn: Callable[[Any], None]) -> bool:
"""Emit if interval has passed, otherwise store as pending. """Emit if interval has passed, otherwise store as pending.
Args: Args:
data: Data to emit data: Data to emit
emit_fn: Function to call with data when emitting emit_fn: Function to call with data when emitting
Returns: Returns:
True if emitted, False if stored as pending True if emitted, False if stored as pending
""" """
now = time.time() now = time.time()
if now - self._last_emit >= self._min_interval: if now - self._last_emit >= self._min_interval:
emit_fn(data) emit_fn(data)
self._last_emit = now self._last_emit = now
self._pending = None self._pending = None
return True return True
else: else:
self._pending = data # Latest value wins self._pending = data # Latest value wins
return False return False
def flush(self, emit_fn: Callable[[Any], None]) -> bool: def flush(self, emit_fn: Callable[[Any], None]) -> bool:
"""Emit pending data if any. """Emit pending data if any.
Call this periodically to ensure pending data gets sent. Call this periodically to ensure pending data gets sent.
Returns: Returns:
True if pending data was emitted, False if nothing pending True if pending data was emitted, False if nothing pending
""" """
if self._pending is not None: if self._pending is not None:
emit_fn(self._pending) emit_fn(self._pending)
self._last_emit = time.time() self._last_emit = time.time()
self._pending = None self._pending = None
return True return True
return False return False
@property @property
def has_pending(self) -> bool: def has_pending(self) -> bool:
"""Check if there's pending data waiting to be emitted.""" """Check if there's pending data waiting to be emitted."""
return self._pending is not None return self._pending is not None

View 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()

View File

@@ -22,11 +22,30 @@ All services use singleton pattern with `ServiceName.instance`.
| Service | Role | | Service | Role |
|---------|------| |---------|------|
| `ConfigService` | Loads `config.json`, exposes settings | | `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 | | `OverheatMonitor` | Polls temp, fires callback when threshold exceeded |
| `ThemeService` | Dark/bright mode state, notifies listeners | | `ThemeService` | Dark/bright mode state, notifies listeners |
| `TestFlipFlopService` | Debug: toggles theme + navigator emotion every 2s | | `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 ## Theme System
- `AppColors` — static color constants (dark/bright variants), auto-generated from JSON - `AppColors` — static color constants (dark/bright variants), auto-generated from JSON

View File

@@ -1,4 +1,6 @@
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'screens/splash_screen.dart'; import 'screens/splash_screen.dart';
import 'screens/dashboard_screen.dart'; import 'screens/dashboard_screen.dart';
@@ -17,7 +19,7 @@ class AppRoot extends StatefulWidget {
class _AppRootState extends State<AppRoot> { class _AppRootState extends State<AppRoot> {
bool _initialized = false; bool _initialized = false;
bool _overheatTriggered = false; bool _overheatTriggered = false;
String _initStatus = 'Starting...'; final Map<String, String> _initStatuses = {};
@override @override
void initState() { void initState() {
@@ -31,25 +33,34 @@ class _AppRootState extends State<AppRoot> {
super.dispose(); super.dispose();
} }
void _updateStatus(String key, String value) {
setState(() => _initStatuses[key] = value);
}
Future<void> _runInitSequence() async { Future<void> _runInitSequence() async {
// Load config first // Show all items from the start so the row doesn't jump around
setState(() => _initStatus = 'Loading config...'); _updateStatus('Config', '...');
_updateStatus('UART', '...');
_updateStatus('GPS', '...');
_updateStatus('Navigator', '...');
// Config must load first (everything else depends on it)
_updateStatus('Config', 'Loading');
await ConfigService.instance.load(); await ConfigService.instance.load();
_updateStatus('Config', 'Ready');
// Simulate init checks - replace with real checks later // UART, GPS, and navigator image preload run truly in parallel
// (UART, GPS, sensors, etc.) _updateStatus('UART', 'Connecting');
_updateStatus('GPS', 'Waiting');
_updateStatus('Navigator', 'Loading');
await Future.wait([
_waitForUart(),
_waitForGps(),
_preloadNavigatorImages(),
]);
setState(() => _initStatus = 'Checking systems...'); // Let the user see the all-ready state for a moment
await Future.delayed(const Duration(milliseconds: 800)); await Future.delayed(const Duration(milliseconds: 500));
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));
// Start overheat monitoring // Start overheat monitoring
OverheatMonitor.instance.start( OverheatMonitor.instance.start(
@@ -61,6 +72,82 @@ class _AppRootState extends State<AppRoot> {
setState(() => _initialized = true); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Determine which screen to show (priority: overheat > splash > dashboard) // Determine which screen to show (priority: overheat > splash > dashboard)
@@ -68,7 +155,7 @@ class _AppRootState extends State<AppRoot> {
if (_overheatTriggered) { if (_overheatTriggered) {
child = const OverheatScreen(key: ValueKey('overheat')); child = const OverheatScreen(key: ValueKey('overheat'));
} else if (!_initialized) { } else if (!_initialized) {
child = SplashScreen(key: const ValueKey('splash'), status: _initStatus); child = SplashScreen(key: const ValueKey('splash'), statuses: _initStatuses);
} else { } else {
child = const DashboardScreen(key: ValueKey('dashboard')); child = const DashboardScreen(key: ValueKey('dashboard'));
} }

View File

@@ -1,237 +1,298 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; 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/backend_service.dart';
import '../services/pi_io.dart'; import '../services/websocket_service.dart';
import '../theme/app_theme.dart'; import '../services/pi_io.dart';
import '../widgets/navigator_widget.dart'; import '../theme/app_theme.dart';
import '../widgets/stat_box.dart'; import '../widgets/navigator_widget.dart';
import '../widgets/stat_box_main.dart'; import '../widgets/stat_box.dart';
import '../widgets/system_bar.dart'; import '../widgets/stat_box_main.dart';
import '../widgets/debug_console.dart'; import '../widgets/system_bar.dart';
import '../widgets/debug_console.dart';
// test service for triggers import '../widgets/whiskey_mark.dart';
import '../services/test_flipflop_service.dart'; import '../widgets/accel_graph.dart';
import '../widgets/gps_compass.dart';
/// Main dashboard - displays Pi vitals and placeholder stats
class DashboardScreen extends StatefulWidget { // test service for triggers
const DashboardScreen({super.key}); import '../services/test_flipflop_service.dart';
@override /// Main dashboard - displays Pi vitals and placeholder stats
State<DashboardScreen> createState() => _DashboardScreenState(); class DashboardScreen extends StatefulWidget {
} const DashboardScreen({super.key});
class _DashboardScreenState extends State<DashboardScreen> { @override
final _navigatorKey = GlobalKey<NavigatorWidgetState>(); State<DashboardScreen> createState() => _DashboardScreenState();
}
// Timer for Pi temp only (safety critical, direct file read)
Timer? _piTempTimer; class _DashboardScreenState extends State<DashboardScreen> {
static const _surpriseThreshold = 0.24; // G threshold for navigator surprise
// WebSocket stream subscriptions
StreamSubscription<ArduinoData>? _arduinoSub; final _navigatorKey = GlobalKey<NavigatorWidgetState>();
StreamSubscription<GpsData>? _gpsSub;
StreamSubscription<WsConnectionState>? _connectionSub; // Timer for Pi temp only (safety critical, direct file read)
Timer? _piTempTimer;
// Pi temperature - direct file read (safety critical)
double? _piTemp; // WebSocket stream subscriptions
StreamSubscription<ArduinoData>? _arduinoSub;
// From backend - Arduino data StreamSubscription<GpsData>? _gpsSub;
int? _rpm; StreamSubscription<LteData>? _lteSub;
double? _voltage; StreamSubscription<WsConnectionState>? _connectionSub;
int? _engineTemp;
int? _gear; // Pi temperature - direct file read (safety critical)
double? _piTemp;
// From backend - GPS data
double? _gpsSpeed; // From backend - Arduino data
int? _rpm;
// Placeholder values for system bar double? _voltage;
int? _gpsSatellites; int? _engineTemp;
int? _lteSignal; int? _gear;
double? _roll;
// WebSocket connection state double? _pitch;
WsConnectionState _wsState = WsConnectionState.disconnected; double? _ax;
double? _ay;
@override double? _dynamicAx; // Gravity-compensated
void initState() { double? _dynamicAy;
super.initState();
// From backend - GPS data
// Connect to WebSocket double? _gpsSpeed;
WebSocketService.instance.connect(); double? _gpsTrack;
// Subscribe to Arduino data stream // Placeholder values for system bar
_arduinoSub = WebSocketService.instance.arduinoStream.listen((data) { int? _gpsSatellites;
setState(() { String? _gpsState;
_voltage = data.voltage; int? _lteSignal;
_rpm = data.rpm;
_engineTemp = data.engTemp; // WebSocket connection state
_gear = data.gear; WsConnectionState _wsState = WsConnectionState.disconnected;
});
}); @override
void initState() {
// Subscribe to GPS data stream super.initState();
_gpsSub = WebSocketService.instance.gpsStream.listen((data) {
setState(() { // Connect to WebSocket
_gpsSpeed = data.speed; WebSocketService.instance.connect();
// Derive satellites from mode (placeholder logic)
_gpsSatellites = data.mode == 3 ? 8 : (data.mode == 2 ? 4 : 0); // Subscribe to Arduino data stream
}); _arduinoSub = WebSocketService.instance.arduinoStream.listen((data) {
}); // Gravity-compensated acceleration
// When tilted, gravity "leaks" into horizontal axes - subtract it out
// Subscribe to connection state final rollRad = (data.roll ?? 0) * pi / 180;
_connectionSub = WebSocketService.instance.connectionStream.listen((state) { final pitchRad = (data.pitch ?? 0) * pi / 180;
setState(() {
_wsState = state; // 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));
// Timer for Pi temp only (safety critical - bypasses backend)
_piTempTimer = Timer.periodic(const Duration(milliseconds: 500), (_) { setState(() {
setState(() { _voltage = data.voltage;
_piTemp = PiIO.instance.getTemperature(); _rpm = data.rpm;
}); _engineTemp = data.engTemp;
}); _gear = data.gear;
_roll = data.roll;
// Initialize with any cached data from WebSocketService _pitch = data.pitch;
final cachedArduino = WebSocketService.instance.latestArduino; _ax = data.ax;
if (cachedArduino != null) { _ay = data.ay;
_voltage = cachedArduino.voltage; _dynamicAx = dynamicAx;
_rpm = cachedArduino.rpm; _dynamicAy = dynamicAy;
_engineTemp = cachedArduino.engTemp; });
_gear = cachedArduino.gear;
} final gMagnitude = sqrt(dynamicAx * dynamicAx + dynamicAy * dynamicAy);
if (gMagnitude > _surpriseThreshold) {
final cachedGps = WebSocketService.instance.latestGps; _navigatorKey.currentState?.setEmotion('surprise');
if (cachedGps != null) { }
_gpsSpeed = cachedGps.speed; });
_gpsSatellites = cachedGps.mode == 3 ? 8 : (cachedGps.mode == 2 ? 4 : 0);
} // Subscribe to GPS data stream
_gpsSub = WebSocketService.instance.gpsStream.listen((data) {
_wsState = WebSocketService.instance.connectionState; setState(() {
_gpsSpeed = data.speed;
// Placeholder: LTE signal (TODO: wire up when LTE service exists) _gpsTrack = data.track;
_lteSignal = null; _gpsSatellites = data.satellites;
_gpsState = data.gpsState;
// DEBUG: flip-flop theme + navigator every 2s });
TestFlipFlopService.instance.start(navigatorKey: _navigatorKey); });
}
// Subscribe to LTE data stream
@override _lteSub = WebSocketService.instance.lteStream.listen((data) {
void dispose() { setState(() {
_piTempTimer?.cancel(); _lteSignal = data.signal;
_arduinoSub?.cancel(); });
_gpsSub?.cancel(); });
_connectionSub?.cancel();
TestFlipFlopService.instance.stop(); // Subscribe to connection state
super.dispose(); _connectionSub = WebSocketService.instance.connectionStream.listen((state) {
} setState(() {
_wsState = state;
/// Format gear for display: null → "—", 0 → "N", 1-6 → "1"-"6" });
String _formatGear(int? gear) { });
if (gear == null) return '';
if (gear == 0) return 'N'; // Timer for Pi temp only (safety critical - bypasses backend)
return gear.toString(); _piTempTimer = Timer.periodic(const Duration(milliseconds: 500), (_) {
} setState(() {
_piTemp = PiIO.instance.getTemperature();
/// Format nullable int for display });
String _formatInt(int? value) => value?.toString() ?? ''; });
/// Format nullable double for display with decimal places // Initialize with any cached data from WebSocketService
String _formatDouble(double? value, [int decimals = 1]) { final cachedArduino = WebSocketService.instance.latestArduino;
if (value == null) return ''; if (cachedArduino != null) {
return value.toStringAsFixed(decimals); _voltage = cachedArduino.voltage;
} _rpm = cachedArduino.rpm;
_engineTemp = cachedArduino.engTemp;
@override _gear = cachedArduino.gear;
Widget build(BuildContext context) { _roll = cachedArduino.roll;
final theme = AppTheme.of(context); _pitch = cachedArduino.pitch;
_ax = cachedArduino.ax;
return Scaffold( _ay = cachedArduino.ay;
backgroundColor: theme.background, }
body: Padding(
padding: const EdgeInsets.all(16), final cachedGps = WebSocketService.instance.latestGps;
child: Row( if (cachedGps != null) {
children: [ _gpsSpeed = cachedGps.speed;
// Left side: All dashboard widgets (flex: 2) _gpsTrack = cachedGps.track;
Expanded( _gpsSatellites = cachedGps.satellites;
flex: 2, _gpsState = cachedGps.gpsState;
child: Column( }
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ _wsState = WebSocketService.instance.connectionState;
// System status bar
SystemBar( // Init from cached LTE data
gpsSatellites: _gpsSatellites, final cachedLte = WebSocketService.instance.latestLte;
lteSignal: _lteSignal, if (cachedLte != null) {
piTemp: _piTemp, _lteSignal = cachedLte.signal;
voltage: _voltage, }
wsState: _wsState,
), // DEBUG: flip-flop theme + navigator every 2s
TestFlipFlopService.instance.start(navigatorKey: _navigatorKey);
const SizedBox(height: 5), }
// Main content area - big stat boxes @override
Expanded( void dispose() {
flex: 8, _piTempTimer?.cancel();
child: Row( _arduinoSub?.cancel();
children: [ _gpsSub?.cancel();
// RPM from Arduino _lteSub?.cancel();
StatBoxMain( _connectionSub?.cancel();
value: _formatInt(_rpm), TestFlipFlopService.instance.stop();
label: 'RPM', super.dispose();
), }
// Add second StatBoxMain here for 2-up layout:
// StatBoxMain(value: '4500', unit: 'rpm', label: 'TACH'), /// 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();
// Bottom stats row }
Expanded(
flex: 2, /// Format nullable int for display
child: Row( String _formatInt(int? value) => value?.toString() ?? '';
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [ /// Format nullable double for display with decimal places
StatBox(value: _formatInt(_rpm), label: 'RPM'), String _formatDouble(double? value, [int decimals = 1]) {
StatBox(value: _formatInt(_engineTemp), unit: '°C', label: 'ENG'), if (value == null) return '';
StatBox(value: _formatGear(_gear), label: 'GEAR'), return value.toStringAsFixed(decimals);
], }
),
), @override
], Widget build(BuildContext context) {
), final theme = AppTheme.of(context);
),
return Scaffold(
const SizedBox(width: 32), backgroundColor: theme.background,
body: Padding(
// Right side: Navigator on top, debug console below padding: const EdgeInsets.all(16),
Expanded( child: Row(
flex: 1, children: [
child: Column( // Left side: All dashboard widgets (flex: 2)
children: [ Expanded(
// Navigator flex: 2,
Expanded( child: Column(
flex: 3, crossAxisAlignment: CrossAxisAlignment.stretch,
child: Center( children: [
child: NavigatorWidget(key: _navigatorKey), // System status bar
), SystemBar(
), gpsSatellites: _gpsSatellites,
// Debug console gpsState: _gpsState,
Expanded( lteSignal: _lteSignal,
flex: 1, piTemp: _piTemp,
child: voltage: _voltage,
DebugConsole( wsState: _wsState,
messageStream: WebSocketService.instance.debugStream, ),
initialMessages: WebSocketService.instance.debugMessages,
maxLines: 6, // Main content area - big widgets
title: 'WebSocket messages', 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',
),
),
],
),
),
],
),
),
);
}
}

View File

@@ -3,10 +3,12 @@ import 'package:flutter/material.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
/// Splash screen - shown during initialization /// Splash screen - shown during initialization
///
/// Displays parallel status items that independently flip to "Ready".
class SplashScreen extends StatelessWidget { 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -33,13 +35,19 @@ class SplashScreen extends StatelessWidget {
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 32),
Text( Row(
status, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
style: Theme.of(context).textTheme.bodyLarge?.copyWith( children: statuses.entries.map((entry) {
fontSize: 80, final isReady = entry.value == 'Ready';
color: theme.subdued, return Text(
), '${entry.key}: ${entry.value}',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontSize: 48,
color: isReady ? theme.foreground : theme.subdued,
),
);
}).toList(),
), ),
], ],
), ),

View File

@@ -1,149 +1,182 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
/// Data from Arduino (voltage, rpm, engine temp, gear) /// Data from Arduino (voltage, rpm, engine temp, gear, IMU)
class ArduinoData { class ArduinoData {
final double? voltage; final double? voltage;
final int? rpm; final int? rpm;
final int? engTemp; final int? engTemp;
final int? gear; // 0 = neutral, 1-6 = gear final int? gear; // 0 = neutral, 1-6 = gear
final double? roll; // Euler angle in degrees (negative = left, positive = right)
ArduinoData({this.voltage, this.rpm, this.engTemp, this.gear}); final double? pitch; // Euler angle in degrees (negative = nose down)
final double? ax; // Lateral acceleration (g)
factory ArduinoData.fromJson(Map<String, dynamic> json) { final double? ay; // Longitudinal acceleration (g)
return ArduinoData( final double? az; // Vertical acceleration (g)
voltage: (json['voltage'] as num?)?.toDouble(),
rpm: (json['rpm'] as num?)?.toInt(), ArduinoData({this.voltage, this.rpm, this.engTemp, this.gear, this.roll, this.pitch, this.ax, this.ay, this.az});
engTemp: (json['eng_temp'] as num?)?.toInt(),
gear: (json['gear'] as num?)?.toInt(), 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(),
/// Data from GPS gear: (json['gear'] as num?)?.toInt(),
class GpsData { roll: (json['roll'] as num?)?.toDouble(), // IMU mounted with axes swapped
final double? lat; pitch: (json['pitch'] as num?)?.toDouble(),
final double? lon; ax: (json['ax'] as num?)?.toDouble(),
final double? speed; // m/s ay: (json['ay'] as num?)?.toDouble(),
final double? alt; az: (json['az'] as num?)?.toDouble(),
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});
/// Data from GPS
factory GpsData.fromJson(Map<String, dynamic> json) { class GpsData {
return GpsData( final double? lat;
lat: (json['lat'] as num?)?.toDouble(), final double? lon;
lon: (json['lon'] as num?)?.toDouble(), final double? speed; // m/s
speed: (json['speed'] as num?)?.toDouble(), final double? alt;
alt: (json['alt'] as num?)?.toDouble(), final double? track;
track: (json['track'] as num?)?.toDouble(), final int? mode; // 0=no fix, 2=2D, 3=3D
mode: (json['mode'] as num?)?.toInt(), 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});
/// HTTP client for Flask backend - fire-and-forget async fetch, sync cache return factory GpsData.fromJson(Map<String, dynamic> json) {
/// return GpsData(
/// Follows the same pattern as PiIO: never blocks UI, always returns cached data. lat: (json['lat'] as num?)?.toDouble(),
class BackendService { lon: (json['lon'] as num?)?.toDouble(),
BackendService._() { speed: (json['speed'] as num?)?.toDouble(),
// Kick off initial fetches alt: (json['alt'] as num?)?.toDouble(),
_refreshArduino(); track: (json['track'] as num?)?.toDouble(),
_refreshGps(); mode: (json['mode'] as num?)?.toInt(),
} satellites: (json['satellites'] as num?)?.toInt(),
static final instance = BackendService._(); gpsState: json['gps_state'] as String?,
);
static const _baseUrl = 'http://127.0.0.1:5000'; }
static const _timeout = Duration(seconds: 2); }
// Caches /// Data from LTE modem (signal quality, connection state)
ArduinoData? _arduinoCache; class LteData {
GpsData? _gpsCache; final bool? connected;
bool _connected = false; final int? signal; // 0-100 percent
final String? operator_;
// In-progress flags (prevent duplicate requests) final String? accessTech;
bool _arduinoFetchInProgress = false;
bool _gpsFetchInProgress = false; LteData({this.connected, this.signal, this.operator_, this.accessTech});
/// Whether backend is reachable factory LteData.fromJson(Map<String, dynamic> json) {
bool get isConnected => _connected; return LteData(
connected: json['connected'] as bool?,
/// Get Arduino data (sync, returns cached value) signal: (json['signal'] as num?)?.toInt(),
ArduinoData? getArduinoData() { operator_: json['operator'] as String?,
if (!_arduinoFetchInProgress) { accessTech: json['access_tech'] as String?,
_refreshArduino(); );
} }
return _arduinoCache; }
}
/// HTTP client for Flask backend - fire-and-forget async fetch, sync cache return
/// Get GPS data (sync, returns cached value) ///
GpsData? getGpsData() { /// Follows the same pattern as PiIO: never blocks UI, always returns cached data.
if (!_gpsFetchInProgress) { class BackendService {
_refreshGps(); BackendService._() {
} // Kick off initial fetches
return _gpsCache; _refreshArduino();
} _refreshGps();
}
/// Background fetch for Arduino data static final instance = BackendService._();
Future<void> _refreshArduino() async {
if (_arduinoFetchInProgress) return; static const _baseUrl = 'http://127.0.0.1:5000';
_arduinoFetchInProgress = true; static const _timeout = Duration(seconds: 2);
try { // Caches
final response = await http ArduinoData? _arduinoCache;
.get(Uri.parse('$_baseUrl/arduino')) GpsData? _gpsCache;
.timeout(_timeout); bool _connected = false;
if (response.statusCode == 200) { // In-progress flags (prevent duplicate requests)
final json = jsonDecode(response.body) as Map<String, dynamic>; bool _arduinoFetchInProgress = false;
// Skip if backend returns error (no data yet) - keep cached value bool _gpsFetchInProgress = false;
if (!json.containsKey('error')) {
_arduinoCache = ArduinoData.fromJson(json); /// Whether backend is reachable
} bool get isConnected => _connected;
_connected = true;
} /// Get Arduino data (sync, returns cached value)
// Non-200: keep cached data, just mark disconnected ArduinoData? getArduinoData() {
} catch (e) { if (!_arduinoFetchInProgress) {
// Network error, timeout, etc - keep cached data for transient hiccups _refreshArduino();
_connected = false; }
} finally { return _arduinoCache;
_arduinoFetchInProgress = false; }
}
} /// Get GPS data (sync, returns cached value)
GpsData? getGpsData() {
/// Background fetch for GPS data if (!_gpsFetchInProgress) {
Future<void> _refreshGps() async { _refreshGps();
if (_gpsFetchInProgress) return; }
_gpsFetchInProgress = true; return _gpsCache;
}
try {
final response = await http /// Background fetch for Arduino data
.get(Uri.parse('$_baseUrl/gps')) Future<void> _refreshArduino() async {
.timeout(_timeout); if (_arduinoFetchInProgress) return;
_arduinoFetchInProgress = true;
if (response.statusCode == 200) {
final json = jsonDecode(response.body) as Map<String, dynamic>; try {
// Skip if backend returns error (no data yet) - keep cached value final response = await http
if (!json.containsKey('error')) { .get(Uri.parse('$_baseUrl/arduino'))
_gpsCache = GpsData.fromJson(json); .timeout(_timeout);
}
_connected = true; if (response.statusCode == 200) {
} final json = jsonDecode(response.body) as Map<String, dynamic>;
// Non-200: keep cached data, just mark disconnected // Skip if backend returns error (no data yet) - keep cached value
} catch (e) { if (!json.containsKey('error')) {
// Network error, timeout, etc - keep cached data for transient hiccups _arduinoCache = ArduinoData.fromJson(json);
_connected = false; }
} finally { _connected = true;
_gpsFetchInProgress = false; }
} // Non-200: keep cached data, just mark disconnected
} } catch (e) {
// Network error, timeout, etc - keep cached data for transient hiccups
/// Force clear all caches _connected = false;
void clearCache() { } finally {
_arduinoCache = null; _arduinoFetchInProgress = false;
_gpsCache = null; }
_connected = 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;
}
}

View File

@@ -86,4 +86,26 @@ class ConfigService {
if (value is String && value.isNotEmpty) return value; if (value is String && value.isNotEmpty) return value;
return _defaultNavigator; 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();
}
} }

View File

@@ -33,11 +33,11 @@ class TestFlipFlopService {
// ThemeService.instance.toggle(); // ThemeService.instance.toggle();
// Surprise the navigator // Surprise the navigator
if (navigatorKey.currentState?.emotion == 'surprise') { // if (navigatorKey.currentState?.emotion == 'surprise') {
navigatorKey.currentState?.reset(); // navigatorKey.currentState?.reset();
} else { // } else {
navigatorKey.currentState?.setEmotion('surprise'); // navigatorKey.currentState?.setEmotion('surprise');
} // }
}); });
} }

View File

@@ -1,314 +1,351 @@
import 'dart:async'; import 'dart:async';
import 'package:socket_io_client/socket_io_client.dart' as io; import 'package:socket_io_client/socket_io_client.dart' as io;
import 'backend_service.dart'; // Reuse ArduinoData, GpsData import 'backend_service.dart'; // Reuse ArduinoData, GpsData
import 'theme_service.dart';
/// Connection state for WebSocket
enum WsConnectionState { /// Connection state for WebSocket
disconnected, enum WsConnectionState {
connecting, disconnected,
connected, connecting,
} connected,
}
/// Acknowledgment from backend for a command
class CommandAck { /// Acknowledgment from backend for a command
final String id; class CommandAck {
final String status; final String id;
final String? error; final String status;
final String? extra; final String? error;
final String? extra;
CommandAck({
required this.id, CommandAck({
required this.status, required this.id,
this.error, required this.status,
this.extra, this.error,
}); this.extra,
});
bool get isSuccess => status == 'ok' || status == 'sent';
} bool get isSuccess => status == 'ok' || status == 'sent';
}
/// Alert from backend
class BackendAlert { /// Alert from backend
final String type; class BackendAlert {
final String message; final String type;
final String message;
BackendAlert({required this.type, required this.message});
} BackendAlert({required this.type, required this.message});
}
/// Backend status (connection states of GPS/Arduino)
class BackendStatus { /// Backend status (connection states of GPS/Arduino)
final bool gpsConnected; class BackendStatus {
final bool arduinoConnected; final bool gpsConnected;
final bool arduinoConnected;
BackendStatus({required this.gpsConnected, required this.arduinoConnected});
} BackendStatus({required this.gpsConnected, required this.arduinoConnected});
}
/// WebSocket service for real-time data from backend.
/// /// WebSocket service for real-time data from backend.
/// Replaces HTTP polling with push-based updates. ///
/// Maintains dual logical channels: /// Replaces HTTP polling with push-based updates.
/// - Telemetry: arduino/gps data streams (throttled by backend) /// Maintains dual logical channels:
/// - Control: button commands and acknowledgments /// - Telemetry: arduino/gps data streams (throttled by backend)
class WebSocketService { /// - Control: button commands and acknowledgments
WebSocketService._() { class WebSocketService {
_setupStreams(); WebSocketService._() {
} _setupStreams();
static final instance = WebSocketService._(); }
static final instance = WebSocketService._();
static const _serverUrl = 'http://127.0.0.1:5000';
static const _serverUrl = 'http://127.0.0.1:5000';
io.Socket? _socket;
WsConnectionState _connectionState = WsConnectionState.disconnected; io.Socket? _socket;
Timer? _reconnectTimer; WsConnectionState _connectionState = WsConnectionState.disconnected;
Timer? _reconnectTimer;
// Latest values for sync access (backward compat)
ArduinoData? _latestArduino; // Latest values for sync access (backward compat)
GpsData? _latestGps; ArduinoData? _latestArduino;
BackendStatus? _latestStatus; GpsData? _latestGps;
LteData? _latestLte;
// Stream controllers BackendStatus? _latestStatus;
late StreamController<ArduinoData> _arduinoController;
late StreamController<GpsData> _gpsController; // Stream controllers
late StreamController<BackendStatus> _statusController; late StreamController<ArduinoData> _arduinoController;
late StreamController<CommandAck> _ackController; late StreamController<GpsData> _gpsController;
late StreamController<BackendAlert> _alertController; late StreamController<LteData> _lteController;
late StreamController<WsConnectionState> _connectionController; late StreamController<BackendStatus> _statusController;
late StreamController<String> _debugController; late StreamController<CommandAck> _ackController;
late StreamController<BackendAlert> _alertController;
// Debug message buffer late StreamController<WsConnectionState> _connectionController;
static const int _maxDebugMessages = 50; late StreamController<String> _debugController;
final List<String> _debugMessages = [];
// Debug message buffer
void _setupStreams() { static const int _maxDebugMessages = 50;
_arduinoController = StreamController<ArduinoData>.broadcast(); final List<String> _debugMessages = [];
_gpsController = StreamController<GpsData>.broadcast();
_statusController = StreamController<BackendStatus>.broadcast(); void _setupStreams() {
_ackController = StreamController<CommandAck>.broadcast(); _arduinoController = StreamController<ArduinoData>.broadcast();
_alertController = StreamController<BackendAlert>.broadcast(); _gpsController = StreamController<GpsData>.broadcast();
_connectionController = StreamController<WsConnectionState>.broadcast(); _lteController = StreamController<LteData>.broadcast();
_debugController = StreamController<String>.broadcast(); _statusController = StreamController<BackendStatus>.broadcast();
} _ackController = StreamController<CommandAck>.broadcast();
_alertController = StreamController<BackendAlert>.broadcast();
/// Log a debug message (adds to buffer and stream) _connectionController = StreamController<WsConnectionState>.broadcast();
void _log(String message) { _debugController = StreamController<String>.broadcast();
_debugMessages.add(message); }
if (_debugMessages.length > _maxDebugMessages) {
_debugMessages.removeAt(0); /// Log a debug message (adds to buffer and stream)
} void _log(String message) {
_debugController.add(message); _debugMessages.add(message);
} if (_debugMessages.length > _maxDebugMessages) {
_debugMessages.removeAt(0);
// --- Public API: Streams --- }
_debugController.add(message);
/// Stream of Arduino telemetry updates }
Stream<ArduinoData> get arduinoStream => _arduinoController.stream;
// --- Public API: Streams ---
/// Stream of GPS updates
Stream<GpsData> get gpsStream => _gpsController.stream; /// Stream of Arduino telemetry updates
Stream<ArduinoData> get arduinoStream => _arduinoController.stream;
/// Stream of backend status updates
Stream<BackendStatus> get statusStream => _statusController.stream; /// Stream of GPS updates
Stream<GpsData> get gpsStream => _gpsController.stream;
/// Stream of command acknowledgments
Stream<CommandAck> get ackStream => _ackController.stream; /// Stream of LTE status updates
Stream<LteData> get lteStream => _lteController.stream;
/// Stream of alerts from backend
Stream<BackendAlert> get alertStream => _alertController.stream; /// Stream of backend status updates
Stream<BackendStatus> get statusStream => _statusController.stream;
/// Stream of connection state changes
Stream<WsConnectionState> get connectionStream => _connectionController.stream; /// Stream of command acknowledgments
Stream<CommandAck> get ackStream => _ackController.stream;
/// Stream of debug log messages
Stream<String> get debugStream => _debugController.stream; /// Stream of alerts from backend
Stream<BackendAlert> get alertStream => _alertController.stream;
/// Current debug message buffer (for initial display)
List<String> get debugMessages => List.unmodifiable(_debugMessages); /// Stream of connection state changes
Stream<WsConnectionState> get connectionStream => _connectionController.stream;
// --- Public API: Sync getters (backward compat) ---
/// Stream of debug log messages
/// Current connection state Stream<String> get debugStream => _debugController.stream;
WsConnectionState get connectionState => _connectionState;
/// Current debug message buffer (for initial display)
/// Whether connected to backend List<String> get debugMessages => List.unmodifiable(_debugMessages);
bool get isConnected => _connectionState == WsConnectionState.connected;
// --- Public API: Sync getters (backward compat) ---
/// Latest Arduino data (may be null if not yet received)
ArduinoData? get latestArduino => _latestArduino; /// Current connection state
WsConnectionState get connectionState => _connectionState;
/// Latest GPS data (may be null if not yet received)
GpsData? get latestGps => _latestGps; /// Whether connected to backend
bool get isConnected => _connectionState == WsConnectionState.connected;
/// Latest backend status
BackendStatus? get latestStatus => _latestStatus; /// Latest Arduino data (may be null if not yet received)
ArduinoData? get latestArduino => _latestArduino;
// --- Public API: Connection ---
/// Latest GPS data (may be null if not yet received)
/// Connect to backend WebSocket GpsData? get latestGps => _latestGps;
void connect() {
if (_socket != null) return; // Already connected or connecting /// Latest LTE data (may be null if not yet received)
LteData? get latestLte => _latestLte;
_setConnectionState(WsConnectionState.connecting);
/// Latest backend status
_socket = io.io(_serverUrl, <String, dynamic>{ BackendStatus? get latestStatus => _latestStatus;
'transports': ['websocket'],
'autoConnect': true, // --- Public API: Connection ---
'reconnection': false, // We handle reconnection ourselves
}); /// Connect to backend WebSocket
void connect() {
_socket!.onConnect((_) { if (_socket != null) return; // Already connected or connecting
_log('connected');
_setConnectionState(WsConnectionState.connected); _setConnectionState(WsConnectionState.connecting);
_cancelReconnect();
}); _socket = io.io(_serverUrl, <String, dynamic>{
'transports': ['websocket'],
_socket!.onDisconnect((_) { 'autoConnect': true,
_log('disconnected'); 'reconnection': false, // We handle reconnection ourselves
_setConnectionState(WsConnectionState.disconnected); });
_scheduleReconnect();
}); _socket!.onConnect((_) {
_log('connected');
_socket!.onConnectError((error) { _setConnectionState(WsConnectionState.connected);
_log('error: $error'); _cancelReconnect();
_setConnectionState(WsConnectionState.disconnected); });
_scheduleReconnect();
}); _socket!.onDisconnect((_) {
_log('disconnected');
_socket!.onError((error) { _setConnectionState(WsConnectionState.disconnected);
_log('error: $error'); _scheduleReconnect();
}); });
// --- Telemetry Events --- _socket!.onConnectError((error) {
_log('error: $error');
_socket!.on('arduino', (data) { _setConnectionState(WsConnectionState.disconnected);
if (data is Map<String, dynamic>) { _scheduleReconnect();
final arduino = ArduinoData.fromJson(data); });
_latestArduino = arduino;
_arduinoController.add(arduino); _socket!.onError((error) {
_log('ard: ${arduino.rpm ?? "-"}rpm ${arduino.voltage ?? "-"}V g${arduino.gear ?? "-"}'); _log('error: $error');
} });
});
// --- Telemetry Events ---
_socket!.on('gps', (data) {
if (data is Map<String, dynamic>) { _socket!.on('arduino', (data) {
final gps = GpsData.fromJson(data); if (data is Map<String, dynamic>) {
_latestGps = gps; final arduino = ArduinoData.fromJson(data);
_gpsController.add(gps); _latestArduino = arduino;
_log('gps: ${gps.speed?.toStringAsFixed(1) ?? "-"}m/s mode${gps.mode ?? "-"}'); _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' : '';
_socket!.on('status', (data) { _log('ard: ${arduino.rpm ?? "-"}rpm ${arduino.voltage ?? "-"}V g${arduino.gear ?? "-"}$imuStr');
if (data is Map<String, dynamic>) {
final status = BackendStatus( // Theme switch piggybacks on arduino packets (edge-triggered from backend)
gpsConnected: data['gps_connected'] ?? false, if (data.containsKey('theme_switch')) {
arduinoConnected: data['arduino_connected'] ?? false, final isDark = data['theme_switch'] as bool;
); ThemeService.instance.setDarkMode(isDark);
_latestStatus = status; _log('theme: ${isDark ? "dark" : "light"}');
_statusController.add(status); }
_log('status: gps=${status.gpsConnected} ard=${status.arduinoConnected}'); }
} });
});
_socket!.on('gps', (data) {
// --- Control Events --- if (data is Map<String, dynamic>) {
final gps = GpsData.fromJson(data);
_socket!.on('ack', (data) { _latestGps = gps;
if (data is Map<String, dynamic>) { _gpsController.add(gps);
final ack = CommandAck( _log('gps: ${gps.speed?.toStringAsFixed(1) ?? "-"}m/s hdg=${gps.track?.round() ?? "-"}° mode${gps.mode ?? "-"}');
id: data['id'] ?? 'unknown', }
status: data['status'] ?? 'unknown', });
error: data['error'],
extra: data['extra'], _socket!.on('lte', (data) {
); if (data is Map<String, dynamic>) {
_ackController.add(ack); final lte = LteData.fromJson(data);
_log('ack: ${ack.id}=${ack.status}${ack.error != null ? " err:${ack.error}" : ""}'); _latestLte = lte;
} _lteController.add(lte);
}); _log('lte: ${lte.signal ?? "-"}% ${lte.operator_ ?? "-"} ${lte.accessTech ?? "-"}');
}
_socket!.on('alert', (data) { });
if (data is Map<String, dynamic>) {
final alert = BackendAlert( _socket!.on('status', (data) {
type: data['type'] ?? 'unknown', if (data is Map<String, dynamic>) {
message: data['message'] ?? '', final status = BackendStatus(
); gpsConnected: data['gps_connected'] ?? false,
_alertController.add(alert); arduinoConnected: data['arduino_connected'] ?? false,
_log('alert: [${alert.type}] ${alert.message}'); );
} _latestStatus = status;
}); _statusController.add(status);
_log('status: gps=${status.gpsConnected} ard=${status.arduinoConnected}');
_socket!.connect();
} // Initial theme state comes with status on connect
if (data.containsKey('theme_switch')) {
/// Disconnect from backend final isDark = data['theme_switch'] as bool;
void disconnect() { ThemeService.instance.setDarkMode(isDark);
_cancelReconnect(); _log('theme: ${isDark ? "dark" : "light"} (initial)');
_socket?.disconnect(); }
_socket?.dispose(); }
_socket = null; });
_setConnectionState(WsConnectionState.disconnected);
} // --- Control Events ---
// --- Public API: Commands --- _socket!.on('ack', (data) {
if (data is Map<String, dynamic>) {
/// Send button event to backend final ack = CommandAck(
void sendButton(String id, String action, [Map<String, dynamic>? params]) { id: data['id'] ?? 'unknown',
if (_socket == null || !isConnected) { status: data['status'] ?? 'unknown',
print('[WS] Cannot send button, not connected'); error: data['error'],
return; extra: data['extra'],
} );
_ackController.add(ack);
final data = <String, dynamic>{ _log('ack: ${ack.id}=${ack.status}${ack.error != null ? " err:${ack.error}" : ""}');
'id': id, }
'action': action, });
...?params,
}; _socket!.on('alert', (data) {
if (data is Map<String, dynamic>) {
_socket!.emit('button', data); final alert = BackendAlert(
} type: data['type'] ?? 'unknown',
message: data['message'] ?? '',
/// Send emergency signal to backend );
void sendEmergency(String type) { _alertController.add(alert);
if (_socket == null) { _log('alert: [${alert.type}] ${alert.message}');
print('[WS] Cannot send emergency, not connected'); }
return; });
}
_socket!.connect();
// Emergency should be sent even if not fully connected }
_socket!.emit('emergency', {'type': type});
} /// Disconnect from backend
void disconnect() {
// --- Private --- _cancelReconnect();
_socket?.disconnect();
void _setConnectionState(WsConnectionState state) { _socket?.dispose();
if (_connectionState != state) { _socket = null;
_connectionState = state; _setConnectionState(WsConnectionState.disconnected);
_connectionController.add(state); }
}
} // --- Public API: Commands ---
void _scheduleReconnect() { /// Send button event to backend
_cancelReconnect(); void sendButton(String id, String action, [Map<String, dynamic>? params]) {
_reconnectTimer = Timer(const Duration(seconds: 3), () { if (_socket == null || !isConnected) {
print('[WS] Attempting reconnect...'); print('[WS] Cannot send button, not connected');
_socket?.dispose(); return;
_socket = null; }
connect();
}); final data = <String, dynamic>{
} 'id': id,
'action': action,
void _cancelReconnect() { ...?params,
_reconnectTimer?.cancel(); };
_reconnectTimer = null;
} _socket!.emit('button', data);
}
/// Dispose all resources (call on app shutdown)
void dispose() { /// Send emergency signal to backend
disconnect(); void sendEmergency(String type) {
_arduinoController.close(); if (_socket == null) {
_gpsController.close(); print('[WS] Cannot send emergency, not connected');
_statusController.close(); return;
_ackController.close(); }
_alertController.close();
_connectionController.close(); // Emergency should be sent even if not fully connected
_debugController.close(); _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();
}
}

View 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;
}
}

View File

@@ -1,119 +1,119 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
/// Generic debug console that displays streaming log messages. /// Generic debug console that displays streaming log messages.
/// ///
/// Can be wired to any message source via [messageStream] and [initialMessages]. /// Can be wired to any message source via [messageStream] and [initialMessages].
/// Example sources: WebSocketService.debugStream, ArduinoService logs, etc. /// Example sources: WebSocketService.debugStream, ArduinoService logs, etc.
class DebugConsole extends StatefulWidget { class DebugConsole extends StatefulWidget {
/// Stream of new messages to display /// Stream of new messages to display
final Stream<String> messageStream; final Stream<String> messageStream;
/// Initial messages to populate (e.g., from a buffer) /// Initial messages to populate (e.g., from a buffer)
final List<String> initialMessages; final List<String> initialMessages;
/// Maximum lines to display /// Maximum lines to display
final int maxLines; final int maxLines;
/// Optional title for the console (shown in title bar) /// Optional title for the console (shown in title bar)
final String? title; final String? title;
const DebugConsole({ const DebugConsole({
super.key, super.key,
required this.messageStream, required this.messageStream,
this.initialMessages = const [], this.initialMessages = const [],
this.maxLines = 8, this.maxLines = 8,
this.title, this.title,
}); });
@override @override
State<DebugConsole> createState() => _DebugConsoleState(); State<DebugConsole> createState() => _DebugConsoleState();
} }
class _DebugConsoleState extends State<DebugConsole> { class _DebugConsoleState extends State<DebugConsole> {
final List<String> _messages = []; final List<String> _messages = [];
StreamSubscription<String>? _sub; StreamSubscription<String>? _sub;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Initialize with existing buffer // Initialize with existing buffer
_messages.addAll(widget.initialMessages); _messages.addAll(widget.initialMessages);
_trimMessages(); _trimMessages();
// Subscribe to new messages // Subscribe to new messages
_sub = widget.messageStream.listen((msg) { _sub = widget.messageStream.listen((msg) {
setState(() { setState(() {
_messages.add(msg); _messages.add(msg);
_trimMessages(); _trimMessages();
}); });
}); });
} }
void _trimMessages() { void _trimMessages() {
while (_messages.length > widget.maxLines) { while (_messages.length > widget.maxLines) {
_messages.removeAt(0); _messages.removeAt(0);
} }
} }
@override @override
void dispose() { void dispose() {
_sub?.cancel(); _sub?.cancel();
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = AppTheme.of(context); final theme = AppTheme.of(context);
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.background.withAlpha(64), color: theme.background.withAlpha(64),
border: Border.all(color: theme.subdued, width: 2), border: Border.all(color: theme.subdued, width: 2),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Column( child: Column(
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// Title bar (optional) // Title bar (optional)
if (widget.title != null) if (widget.title != null)
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border( border: Border(
bottom: BorderSide(color: theme.subdued, width: 1), bottom: BorderSide(color: theme.subdued, width: 1),
), ),
), ),
child: Text( child: Text(
widget.title!, widget.title!,
style: TextStyle( style: TextStyle(
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: 24, fontSize: 24,
color: theme.subdued, color: theme.subdued,
), ),
), ),
), ),
// Console content // Console content
Expanded( Expanded(
child: Padding( child: Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: Text( child: Text(
_messages.isEmpty ? '(no messages)' : _messages.join('\n'), _messages.isEmpty ? '(no messages)' : _messages.join('\n'),
style: TextStyle( style: TextStyle(
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: 30, fontSize: 30,
color: theme.foreground, color: theme.foreground,
height: 1.0, height: 1.0,
), ),
), ),
), ),
), ),
], ],
), ),
); );
} }
} }

View 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',
),
),
),
),
],
);
}
}

View File

@@ -1,59 +1,100 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'dart:math';
import '../services/config_service.dart'; import 'package:flutter/material.dart';
import '../services/config_service.dart';
/// Displays the navigator character with emotion support.
/// /// Displays the navigator character with emotion support.
/// Use a GlobalKey to control emotions from parent: ///
/// ```dart /// Use a GlobalKey to control emotions from parent:
/// final _navigatorKey = GlobalKey<NavigatorWidgetState>(); /// ```dart
/// NavigatorWidget(key: _navigatorKey) /// final _navigatorKey = GlobalKey<NavigatorWidgetState>();
/// // Later: /// NavigatorWidget(key: _navigatorKey)
/// _navigatorKey.currentState?.setEmotion('happy'); /// // Later:
/// ``` /// _navigatorKey.currentState?.setEmotion('happy');
class NavigatorWidget extends StatefulWidget { /// ```
const NavigatorWidget({super.key}); class NavigatorWidget extends StatefulWidget {
const NavigatorWidget({super.key});
@override
State<NavigatorWidget> createState() => NavigatorWidgetState(); @override
} State<NavigatorWidget> createState() => NavigatorWidgetState();
}
class NavigatorWidgetState extends State<NavigatorWidget> {
String _emotion = 'default'; class NavigatorWidgetState extends State<NavigatorWidget>
with SingleTickerProviderStateMixin {
/// Change the displayed emotion. String _emotion = 'default';
/// Image file must exist at: {assetsPath}/navigator/{navigator}/{emotion}.png late AnimationController _shakeController;
void setEmotion(String emotion) {
if (emotion != _emotion) { @override
setState(() => _emotion = emotion); void initState() {
} super.initState();
} _shakeController = AnimationController(
duration: const Duration(milliseconds: 400),
/// Reset to default emotion vsync: this,
void reset() => setEmotion('default'); );
// Auto-reset to default after surprise animation completes
/// Current emotion _shakeController.addStatusListener((status) {
String get emotion => _emotion; if (status == AnimationStatus.completed && _emotion == 'surprise') {
setState(() => _emotion = 'default');
@override }
Widget build(BuildContext context) { });
final config = ConfigService.instance; }
final basePath = '${config.assetsPath}/navigator/${config.navigator}';
@override
return Image.file( void dispose() {
File('$basePath/$_emotion.png'), _shakeController.dispose();
fit: BoxFit.contain, super.dispose();
errorBuilder: (context, error, stackTrace) { }
// Fallback: try default.png if specific emotion missing
if (_emotion != 'default') { /// Change the displayed emotion.
return Image.file( /// Image file must exist at: {assetsPath}/navigator/{navigator}/{emotion}.png
File('$basePath/default.png'), void setEmotion(String emotion) {
fit: BoxFit.contain, if (emotion != _emotion) {
errorBuilder: (_, __, ___) => const SizedBox.shrink(), setState(() => _emotion = emotion);
); if (emotion == 'surprise') {
} _shakeController.forward(from: 0);
return const SizedBox.shrink(); }
}, }
); }
}
} /// 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,
);
},
);
}
}

View File

@@ -9,17 +9,23 @@ class StatBox extends StatelessWidget {
final String label; final String label;
final int flex; final int flex;
/// Optional warning predicate - if returns true, value shows in highlight color
final bool Function()? isWarning;
const StatBox({ const StatBox({
super.key, super.key,
required this.value, required this.value,
this.unit, this.unit,
required this.label, required this.label,
this.flex = 1, this.flex = 1,
this.isWarning,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = AppTheme.of(context); final theme = AppTheme.of(context);
final warning = isWarning?.call() ?? false;
final valueColor = warning ? theme.highlight : theme.foreground;
return Expanded( return Expanded(
flex: flex, flex: flex,
@@ -42,7 +48,7 @@ class StatBox extends StatelessWidget {
fontSize: baseSize, fontSize: baseSize,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
fontFeatures: const [FontFeature.tabularFigures()], fontFeatures: const [FontFeature.tabularFigures()],
color: theme.foreground, color: valueColor,
height: 1, height: 1,
), ),
), ),

View File

@@ -1,178 +1,174 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../services/websocket_service.dart'; import '../services/websocket_service.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
/// Android-style persistent status bar for system indicators. /// Android-style persistent status bar for system indicators.
/// Shows GPS satellites, LTE signal, Pi temp, voltage, WS status at a glance. /// Shows GPS satellites, LTE signal, Pi temp, voltage, WS status at a glance.
class SystemBar extends StatelessWidget { class SystemBar extends StatelessWidget {
final int? gpsSatellites; // null = disconnected final int? gpsSatellites; // null = disconnected
final int? lteSignal; // null = disconnected, 0-4 bars final String? gpsState; // "acquiring", "fix", "lost"
final double? piTemp; // null = unavailable final int? lteSignal; // null = disconnected, 0-4 bars
final double? voltage; // null = Arduino disconnected final double? piTemp; // null = unavailable
final WsConnectionState? wsState; // WebSocket connection state final double? voltage; // null = Arduino disconnected
final WsConnectionState? wsState; // WebSocket connection state
const SystemBar({
super.key, const SystemBar({
this.gpsSatellites, super.key,
this.lteSignal, this.gpsSatellites,
this.piTemp, this.gpsState,
this.voltage, this.lteSignal,
this.wsState, this.piTemp,
}); this.voltage,
this.wsState,
/// Get WebSocket status text and abnormal flag });
(String, bool) _wsStatus() {
switch (wsState) { /// Get WebSocket status text and abnormal flag
case WsConnectionState.connected: (String, bool) _wsStatus() {
return ('OK', false); switch (wsState) {
case WsConnectionState.connecting: case WsConnectionState.connected:
return ('...', true); return ('OK', false);
case WsConnectionState.disconnected: case WsConnectionState.connecting:
case null: return ('...', true);
return ('OFF', true); case WsConnectionState.disconnected:
} case null:
} return ('OFF', true);
}
@override }
Widget build(BuildContext context) {
final theme = AppTheme.of(context); @override
final (wsText, wsAbnormal) = _wsStatus(); Widget build(BuildContext context) {
final theme = AppTheme.of(context);
return Expanded( final (wsText, wsAbnormal) = _wsStatus();
flex: 1,
child: LayoutBuilder( return Expanded(
builder: (context, constraints) { flex: 1,
// Font sizes relative to bar height child: LayoutBuilder(
final labelSize = constraints.maxHeight * 0.5; builder: (context, constraints) {
final valueSize = constraints.maxHeight * 0.5; // Font sizes relative to bar height
final labelSize = constraints.maxHeight * 0.5;
return Container( final valueSize = constraints.maxHeight * 0.5;
padding: const EdgeInsets.symmetric(horizontal: 24),
decoration: BoxDecoration( return Container(
border: Border( padding: const EdgeInsets.symmetric(horizontal: 24),
bottom: BorderSide( child: Row(
color: theme.subdued.withValues(alpha: 0.3), crossAxisAlignment: CrossAxisAlignment.center,
width: 1, children: [
), // Left group: WS, GPS, LTE
), _Indicator(
), label: 'WS',
child: Row( value: wsText,
crossAxisAlignment: CrossAxisAlignment.center, isAbnormal: wsAbnormal,
children: [ alignment: Alignment.centerLeft,
// Left group: WS, GPS, LTE labelSize: labelSize,
_Indicator( valueSize: valueSize,
label: 'WS', flex: 2,
value: wsText, theme: theme,
isAbnormal: wsAbnormal, ),
alignment: Alignment.centerLeft, _Indicator(
labelSize: labelSize, label: 'GPS',
valueSize: valueSize, value: gpsState == 'acquiring' ? 'ACQ'
flex: 2, : gpsState == 'fix' ? (gpsSatellites?.toString() ?? 'N/A')
theme: theme, : '0', // lost or unknown
), isAbnormal: gpsState != 'fix' || gpsSatellites == null,
_Indicator( alignment: Alignment.centerLeft,
label: 'GPS', labelSize: labelSize,
value: gpsSatellites?.toString() ?? 'N/A', valueSize: valueSize,
isAbnormal: gpsSatellites == null || gpsSatellites == 0, flex: 2,
alignment: Alignment.centerLeft, theme: theme,
labelSize: labelSize, ),
valueSize: valueSize, _Indicator(
flex: 2, label: 'LTE',
theme: theme, value: lteSignal?.toString() ?? 'N/A',
), isAbnormal: lteSignal == null,
_Indicator( alignment: Alignment.centerLeft,
label: 'LTE', labelSize: labelSize,
value: lteSignal?.toString() ?? 'N/A', valueSize: valueSize,
isAbnormal: lteSignal == null, flex: 2,
alignment: Alignment.centerLeft, theme: theme,
labelSize: labelSize, ),
valueSize: valueSize,
flex: 2, // Right group: Pi, Chassis
theme: theme, _Indicator(
), label: 'Pi',
value: piTemp != null ? '${piTemp!.toStringAsFixed(1)} °C' : 'N/A',
// Right group: Pi, Chassis isAbnormal: piTemp == null || piTemp! > 80,
_Indicator( alignment: Alignment.centerLeft,
label: 'Pi', labelSize: labelSize,
value: piTemp != null ? '${piTemp!.toStringAsFixed(1)} °C' : 'N/A', valueSize: valueSize,
isAbnormal: piTemp == null || piTemp! > 80, flex: 2,
alignment: Alignment.centerLeft, theme: theme,
labelSize: labelSize, ),
valueSize: valueSize, _Indicator(
flex: 2, label: 'Mains',
theme: theme, value: voltage != null ? '${voltage!.toStringAsFixed(1)} V' : 'N/A',
), isAbnormal: voltage == null || voltage! < 11.7 || voltage! > 14.5,
_Indicator( alignment: Alignment.centerLeft,
label: 'Mains', labelSize: labelSize,
value: voltage != null ? '${voltage!.toStringAsFixed(1)} V' : 'N/A', valueSize: valueSize,
isAbnormal: voltage == null || voltage! < 11.9, flex: 3,
alignment: Alignment.centerLeft, theme: theme,
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;
/// Single status indicator in a fixed-width flex slot. final bool isAbnormal;
class _Indicator extends StatelessWidget { final Alignment alignment;
final String label; final double labelSize;
final String value; final double valueSize;
final bool isAbnormal; final int flex;
final Alignment alignment; final AppTheme theme;
final double labelSize;
final double valueSize; const _Indicator({
final int flex; required this.label,
final AppTheme theme; required this.value,
required this.isAbnormal,
const _Indicator({ required this.alignment,
required this.label, required this.labelSize,
required this.value, required this.valueSize,
required this.isAbnormal, required this.flex,
required this.alignment, required this.theme,
required this.labelSize, });
required this.valueSize,
required this.flex, @override
required this.theme, Widget build(BuildContext context) {
}); return Expanded(
flex: flex,
@override child: Align(
Widget build(BuildContext context) { alignment: alignment,
return Expanded( child: Row(
flex: flex, mainAxisSize: MainAxisSize.min,
child: Align( crossAxisAlignment: CrossAxisAlignment.baseline,
alignment: alignment, textBaseline: TextBaseline.alphabetic,
child: Row( children: [
mainAxisSize: MainAxisSize.min, Text(
crossAxisAlignment: CrossAxisAlignment.baseline, '$label ',
textBaseline: TextBaseline.alphabetic, style: TextStyle(
children: [ fontSize: labelSize,
Text( color: theme.subdued,
'$label ', ),
style: TextStyle( ),
fontSize: labelSize, Text(
color: theme.subdued, value,
), style: TextStyle(
), fontSize: valueSize,
Text( fontFeatures: const [FontFeature.tabularFigures()],
value, color: isAbnormal ? theme.highlight : theme.foreground,
style: TextStyle( ),
fontSize: valueSize, ),
fontFeatures: const [FontFeature.tabularFigures()], ],
color: isAbnormal ? theme.highlight : theme.foreground, ),
), ),
), );
], }
), }
),
);
}
}

View 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;
}
}

View File

@@ -1,26 +1,26 @@
name: smartserow_ui name: smartserow_ui
description: Smart Serow embedded UI for Raspberry Pi Zero 2W description: Smart Serow embedded UI for Raspberry Pi Zero 2W
publish_to: 'none' publish_to: 'none'
version: 0.1.0 version: 0.1.0
environment: environment:
sdk: '>=3.0.0 <4.0.0' sdk: '>=3.0.0 <4.0.0'
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
http: ^1.2.0 http: ^1.2.0
socket_io_client: ^2.0.3+1 socket_io_client: ^2.0.3+1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_lints: ^3.0.0 flutter_lints: ^3.0.0
flutter: flutter:
uses-material-design: true uses-material-design: true
fonts: fonts:
- family: DIN1451 - family: DIN1451
fonts: fonts:
- asset: assets/fonts/din1451alt.ttf - asset: assets/fonts/din1451alt.ttf

View File

@@ -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) | | `pi_setup_backend.sh` | First-time Pi config for backend (uv, gpsd, systemd) |
| `smartserow-ui.service.sample` | UI systemd unit template | | `smartserow-ui.service.sample` | UI systemd unit template |
| `smartserow-backend.service.sample` | Backend 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 ```bash
# On the Pi - UI setup # On the Pi - UI setup

View File

@@ -1,88 +1,88 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""One-click build and deploy for Smart Serow. """One-click build and deploy for Smart Serow.
Combines build.py and deploy.py with sensible defaults. Combines build.py and deploy.py with sensible defaults.
Defaults to --restart since that's usually what you want. Defaults to --restart since that's usually what you want.
""" """
import argparse import argparse
import sys import sys
from pathlib import Path from pathlib import Path
# Import sibling modules # Import sibling modules
sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(Path(__file__).parent))
from build import build from build import build
from deploy import deploy from deploy import deploy
from deploy_backend import deploy as deploy_backend from deploy_backend import deploy as deploy_backend
def main(): def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Build and deploy Smart Serow in one step", description="Build and deploy Smart Serow in one step",
) )
parser.add_argument( parser.add_argument(
"--clean", "-c", "--clean", "-c",
action="store_true", action="store_true",
help="Clean CMake cache before building", help="Clean CMake cache before building",
) )
parser.add_argument( parser.add_argument(
"--no-restart", "--no-restart",
action="store_true", action="store_true",
help="Don't restart service after deploy (default: restart)", help="Don't restart service after deploy (default: restart)",
) )
parser.add_argument( parser.add_argument(
"--build-only", "--build-only",
action="store_true", action="store_true",
help="Only build, don't deploy", help="Only build, don't deploy",
) )
parser.add_argument( parser.add_argument(
"--deploy-only", "--deploy-only",
action="store_true", action="store_true",
help="Only deploy, don't build", help="Only deploy, don't build",
) )
parser.add_argument( parser.add_argument(
"--ui", "--ui",
action="store_true", action="store_true",
help="Build/deploy UI only (no backend)", help="Build/deploy UI only (no backend)",
) )
parser.add_argument( parser.add_argument(
"--backend", "--backend",
action="store_true", action="store_true",
help="Deploy backend only (no UI, no build)", help="Deploy backend only (no UI, no build)",
) )
args = parser.parse_args() args = parser.parse_args()
# Default: both UI and backend if neither flag specified # Default: both UI and backend if neither flag specified
do_ui = args.ui or not args.backend do_ui = args.ui or not args.backend
do_backend = args.backend or not args.ui do_backend = args.backend or not args.ui
restart = not args.no_restart restart = not args.no_restart
# Build UI (only if doing UI and not deploy-only) # Build UI (only if doing UI and not deploy-only)
if do_ui and not args.deploy_only: if do_ui and not args.deploy_only:
print() print()
if not build(clean=args.clean): if not build(clean=args.clean):
print("UI build failed!") print("UI build failed!")
sys.exit(1) sys.exit(1)
# Deploy backend FIRST (no build step needed - it's Python) # Deploy backend FIRST (no build step needed - it's Python)
# Backend must be up before UI connects to WebSocket # Backend must be up before UI connects to WebSocket
if do_backend and not args.build_only: if do_backend and not args.build_only:
print() print()
if not deploy_backend(restart=restart): if not deploy_backend(restart=restart):
print("Backend deploy failed!") print("Backend deploy failed!")
sys.exit(1) sys.exit(1)
# Deploy UI after backend is ready # Deploy UI after backend is ready
if do_ui and not args.build_only: if do_ui and not args.build_only:
print() print()
if not deploy(restart=restart): if not deploy(restart=restart):
print("UI deploy failed!") print("UI deploy failed!")
sys.exit(1) sys.exit(1)
print() print()
print("=== All done! ===") print("=== All done! ===")
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@@ -1,127 +1,157 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Deploy script for Smart Serow Python backend. """Deploy script for Smart Serow Python backend.
Pushes backend source to Pi and optionally restarts service. Pushes backend source to Pi and optionally restarts service.
Completely independent from UI deploy. Completely independent from UI deploy.
""" """
import argparse import argparse
import json import json
import subprocess import subprocess
import sys import sys
import time import time
from pathlib import Path from pathlib import Path
SCRIPT_DIR = Path(__file__).parent.resolve() SCRIPT_DIR = Path(__file__).parent.resolve()
PROJECT_ROOT = SCRIPT_DIR.parent PROJECT_ROOT = SCRIPT_DIR.parent
CONFIG_FILE = SCRIPT_DIR / "deploy_target.json" CONFIG_FILE = SCRIPT_DIR / "deploy_target.json"
BACKEND_DIR = PROJECT_ROOT / "pi" / "backend" 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.""" def run(cmd: list[str], check: bool = True, **kwargs) -> subprocess.CompletedProcess:
print(f"{' '.join(cmd)}") """Run a command."""
return subprocess.run(cmd, check=check, **kwargs) print(f"{' '.join(cmd)}")
return subprocess.run(cmd, check=check, **kwargs)
def load_config() -> dict:
"""Load deploy target configuration.""" def load_config() -> dict:
if not CONFIG_FILE.exists(): """Load deploy target configuration."""
print(f"ERROR: Config file not found: {CONFIG_FILE}") if not CONFIG_FILE.exists():
print("Create it based on deploy_target.sample.json") print(f"ERROR: Config file not found: {CONFIG_FILE}")
sys.exit(1) print("Create it based on deploy_target.sample.json")
sys.exit(1)
with open(CONFIG_FILE) as f:
return json.load(f) with open(CONFIG_FILE) as f:
return json.load(f)
def deploy(restart: bool = False) -> bool:
"""Deploy backend to Pi. Returns True on success.""" def deploy(restart: bool = False) -> bool:
config = load_config() """Deploy backend to Pi. Returns True on success."""
config = load_config()
pi_user = config["user"]
pi_host = config["host"] pi_user = config["user"]
pi_host = config["host"]
# Backend-specific config (with defaults)
remote_path = config.get("backend_path", "/opt/smartserow-backend") # Backend-specific config (with defaults)
service_name = config.get("backend_service", "smartserow-backend") remote_path = config.get("backend_path", "/opt/smartserow-backend")
service_name = config.get("backend_service", "smartserow-backend")
ssh_target = f"{pi_user}@{pi_host}"
ssh_target = f"{pi_user}@{pi_host}"
print("=== Smart Serow Backend Deploy ===")
print(f"Target: {ssh_target}:{remote_path}") print("=== Smart Serow Backend Deploy ===")
print(f"Source: {BACKEND_DIR}") 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}") if not BACKEND_DIR.exists():
return False print(f"ERROR: Backend directory not found: {BACKEND_DIR}")
return False
# Ensure remote directory exists
print() # Ensure remote directory exists
print("Ensuring remote directory...") print()
run(["ssh", ssh_target, f"mkdir -p {remote_path}"]) print("Ensuring remote directory...")
run(["ssh", ssh_target, f"mkdir -p {remote_path}"])
# Sync backend source to Pi
# Exclude __pycache__, .venv, etc. # Sync backend source to Pi
print() # Exclude __pycache__, .venv, etc.
print("Syncing files...") print()
run([ print("Syncing files...")
"rsync", "-avz", "--delete", run([
"--exclude", "__pycache__", "rsync", "-avz", "--delete",
"--exclude", "*.pyc", "--exclude", "__pycache__",
"--exclude", ".venv", "--exclude", "*.pyc",
"--exclude", ".ruff_cache", "--exclude", ".venv",
f"{BACKEND_DIR}/", "--exclude", ".ruff_cache",
f"{ssh_target}:{remote_path}/", "--exclude", "uv.lock", # Let Pi generate its own lockfile
]) 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() # Ensure system GPIO package is installed (pip version needs compilation)
print("Running uv sync...") print()
result = run( print("Ensuring system GPIO package...")
["ssh", ssh_target, f"cd {remote_path} && ~/.local/bin/uv sync"], run(
check=False, ["ssh", ssh_target, "dpkg -s python3-rpi.gpio >/dev/null 2>&1 || sudo apt install -y python3-rpi.gpio"],
) 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") # Create venv with system-site-packages if it doesn't exist
# This allows access to apt-installed packages like python3-rpi.gpio
# Restart service if requested print()
if restart: print("Ensuring venv with system-site-packages...")
print() run(
print(f"Restarting service: {service_name}") ["ssh", ssh_target, f"cd {remote_path} && [ -d .venv ] || ~/.local/bin/uv venv --system-site-packages"],
run(["ssh", ssh_target, f"sudo systemctl restart {service_name}"], check=False) check=False,
time.sleep(2) )
run(["ssh", ssh_target, f"systemctl status {service_name} --no-pager"], check=False)
else: # Run uv sync to install/update dependencies
print() # Use full path since non-interactive SSH doesn't load .bashrc
print("Deploy complete. To restart service, run:") print()
print(f" ssh {ssh_target} 'sudo systemctl restart {service_name}'") print("Running uv sync...")
print() result = run(
print("Or run this script with --restart flag") ["ssh", ssh_target, f"cd {remote_path} && ~/.local/bin/uv sync"],
check=False,
print() )
print("Note: First-time setup on Pi requires uv to be installed:") if result.returncode != 0:
print(f" ssh {ssh_target}") print("WARNING: uv sync failed - dependencies may be out of date")
print(" curl -LsSf https://astral.sh/uv/install.sh | sh") print("Make sure uv is installed on Pi: curl -LsSf https://astral.sh/uv/install.sh | sh")
return True # Deploy service file if it exists
if SERVICE_FILE.exists():
print()
def main(): print("Deploying systemd service file...")
parser = argparse.ArgumentParser(description="Deploy Smart Serow backend to Pi") run(["scp", str(SERVICE_FILE), f"{ssh_target}:/tmp/"])
parser.add_argument( run([
"--restart", "-r", "ssh", ssh_target,
action="store_true", f"sudo mv /tmp/{SERVICE_FILE.name} /etc/systemd/system/ && sudo systemctl daemon-reload"
help="Restart the systemd service after deploy", ])
)
args = parser.parse_args() # Restart service if requested
if restart:
success = deploy(restart=args.restart) print()
sys.exit(0 if success else 1) print(f"Restarting service: {service_name}")
run(["ssh", ssh_target, f"sudo systemctl restart {service_name}"], check=False)
time.sleep(2)
if __name__ == "__main__": run(["ssh", ssh_target, f"systemctl status {service_name} --no-pager"], check=False)
main() 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()

View File

@@ -27,10 +27,10 @@ else
echo "uv already installed: $(uv --version)" echo "uv already installed: $(uv --version)"
fi fi
# Install gpsd # Install gpsd and GPIO support
echo "Installing gpsd..." echo "Installing system packages..."
sudo apt-get update 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) # Configure gpsd (user needs to edit DEVICES)
GPSD_CONFIG="/etc/default/gpsd" GPSD_CONFIG="/etc/default/gpsd"
@@ -66,7 +66,10 @@ echo ""
echo "Next steps:" echo "Next steps:"
echo "1. Configure gpsd: sudo nano /etc/default/gpsd" echo "1. Configure gpsd: sudo nano /etc/default/gpsd"
echo "2. Deploy backend: python3 scripts/deploy_backend.py (from dev machine)" 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 "4. Start service: sudo systemctl start smartserow-backend"
echo "" echo ""
echo "Useful commands:" echo "Useful commands:"