diff --git a/IDEAS.md b/IDEAS.md index 9027dd2..1ded30e 100644 --- a/IDEAS.md +++ b/IDEAS.md @@ -1,5 +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) +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. \ No newline at end of file diff --git a/arduino/.gitignore b/arduino/.gitignore index bc1f08c..5c0b59c 100644 --- a/arduino/.gitignore +++ b/arduino/.gitignore @@ -1,3 +1,3 @@ -# arduino test files - +# arduino test files + test/ \ No newline at end of file diff --git a/pi/backend/README.md b/pi/backend/README.md index 69d1726..f1581f2 100644 --- a/pi/backend/README.md +++ b/pi/backend/README.md @@ -178,6 +178,19 @@ The Pi's internal pull-down (~50kΩ) will overpower high-value external resistor 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 TODO: Add to `scripts/deploy.py` as second target + systemd service. diff --git a/pi/backend/arduino_service.py b/pi/backend/arduino_service.py index feb5f35..1c34b1d 100644 --- a/pi/backend/arduino_service.py +++ b/pi/backend/arduino_service.py @@ -1,308 +1,308 @@ -"""Arduino service - connects to Arduino Nano via serial, buffers telemetry.""" - -import json -import math -import re -import threading -import time -from collections import deque -from typing import Any - -# pyserial for UART communication -try: - import serial -except ImportError: - serial = None # Allow import without pyserial for testing structure - - -class ArduinoService: - """Threaded Arduino serial reader with buffering and auto-reconnect.""" - - # TSV field names (order per PROTOCOL.md) - TSV_FIELDS = ['voltage', 'ax', 'ay', 'az', 'gx', 'gy', 'gz', 'roll', 'pitch', 'yaw', 'rpm', 'gear'] - - # Regex patterns for legacy text protocol (backwards compatibility) - PATTERNS = { - "voltage": re.compile(r"V_bat:\s*(\d+\.?\d*)V?", re.IGNORECASE), - "rpm": re.compile(r"RPM:\s*(\d+)", re.IGNORECASE), - "eng_temp": re.compile(r"ENG:\s*(\d+)C?", re.IGNORECASE), - "gear": re.compile(r"GEAR:\s*(\d+)", re.IGNORECASE), - } - - # ACK pattern: "ACK:CMD:STATUS" or "ACK:CMD:STATUS:extra" - ACK_PATTERN = re.compile(r"ACK:(\w+):(\w+)(?::(.*))?") - - def __init__( - self, - port: str = "/dev/serial0", - baudrate: int = 115200, - buffer_size: int = 100, - ): - self.port = port - self.baudrate = baudrate - self.buffer_size = buffer_size - - self._buffer: deque[dict[str, Any]] = deque(maxlen=buffer_size) - self._latest: dict[str, Any] = {} - self._connected = False - self._running = False - self._thread: threading.Thread | None = None - self._lock = threading.Lock() - - # Callbacks for push-based updates - self._on_data_callback = None - self._on_ack_callback = None - - # Serial port handle for sending commands - self._serial: Any = None - self._serial_lock = threading.Lock() - - # Periodic status logging - self._last_status_log = 0.0 - self._frame_count = 0 - - def set_on_data(self, callback): - """Set callback for new telemetry data. Called with data dict.""" - self._on_data_callback = callback - - def set_on_ack(self, callback): - """Set callback for ACK responses. Called with (cmd, status, extra).""" - self._on_ack_callback = callback - - def send_command(self, cmd: str, params: dict | None = None) -> bool: - """Send a command to Arduino via serial. - - Format: "CMD:NAME:PARAM1:PARAM2..." followed by newline - - Args: - cmd: Command name (e.g., "HORN", "LIGHT") - params: Optional parameters dict - - Returns: - True if sent successfully, False if serial unavailable - """ - with self._serial_lock: - if self._serial is None or not self._connected: - print(f"[Arduino] Cannot send command, not connected") - return False - - try: - # Build command string - parts = ["CMD", cmd.upper()] - if params: - for key, val in params.items(): - parts.append(f"{key}={val}") - line = ":".join(parts) + "\n" - - self._serial.write(line.encode("utf-8")) - self._serial.flush() - print(f"[Arduino] Sent: {line.strip()}") - return True - except Exception as e: - print(f"[Arduino] Failed to send command: {e}") - return False - - @property - def connected(self) -> bool: - return self._connected - - def get_latest(self) -> dict[str, Any]: - """Get most recent telemetry values.""" - with self._lock: - return self._latest.copy() if self._latest else {"error": "no data"} - - def get_buffer(self) -> list[dict[str, Any]]: - """Get buffered telemetry history.""" - with self._lock: - return list(self._buffer) - - def start(self): - """Start background serial reader thread.""" - if self._running: - return - self._running = True - self._thread = threading.Thread(target=self._reader_loop, daemon=True) - self._thread.start() - - def stop(self): - """Stop background reader.""" - self._running = False - if self._thread: - self._thread.join(timeout=2.0) - - def _reader_loop(self): - """Main reader loop with reconnection logic.""" - while self._running: - try: - self._connect_and_read() - except Exception as e: - self._connected = False - print(f"[Arduino] Connection error: {e}, retrying in 5s...") - time.sleep(5) - - def _connect_and_read(self): - """Connect to Arduino serial and read data.""" - if serial is None: - print("[Arduino] pyserial not installed, cannot connect") - return # Will retry via _reader_loop after 5s - - try: - ser = serial.Serial( - port=self.port, - baudrate=self.baudrate, - timeout=1.0, - ) - except serial.SerialException as e: - print(f"[Arduino] Cannot open {self.port}: {e}") - return # Will retry via _reader_loop after 5s - - try: - # Store serial handle for send_command() - with self._serial_lock: - self._serial = ser - self._connected = True - self._last_status_log = time.time() - self._frame_count = 0 - print(f"[Arduino] Connected to {self.port} @ {self.baudrate} baud") - - while self._running: - try: - # Read null-terminated line (TSV protocol) - line = self._read_null_terminated(ser) - if not line: - continue - - # Check for ACK responses first (legacy newline-terminated) - ack_match = self.ACK_PATTERN.match(line) - if ack_match: - cmd, status, extra = ack_match.groups() - if self._on_ack_callback: - self._on_ack_callback(cmd, status, extra) - continue - - data = self._parse_line(line) - if data: - data["time"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) - with self._lock: - # Merge new values into latest (preserve old values for partial updates) - for key, val in data.items(): - if val is not None and not (isinstance(val, float) and math.isnan(val)): - self._latest[key] = val - self._latest["time"] = data["time"] - self._buffer.append(self._latest.copy()) - - # Invoke callback with new data - if self._on_data_callback: - self._on_data_callback(self._latest.copy()) - - # Periodic status log (every 5s) - self._frame_count += 1 - now = time.time() - if now - self._last_status_log >= 5.0: - elapsed = now - self._last_status_log - fps = self._frame_count / elapsed - v = self._latest.get('voltage', 0) - rpm = self._latest.get('rpm', 0) - gear = self._latest.get('gear', 0) - roll = self._latest.get('roll', 0) - print(f"[Arduino] {fps:.1f} fps | V={v:.1f} RPM={int(rpm)} G={int(gear)} roll={roll:.1f}°") - self._last_status_log = now - self._frame_count = 0 - - except serial.SerialException as e: - print(f"[Arduino] Serial error: {e}") - break - - finally: - self._connected = False - with self._serial_lock: - self._serial = None - ser.close() - - def _read_null_terminated(self, ser) -> str: - """Read bytes until null terminator or newline (fallback for legacy).""" - buf = bytearray() - while self._running: - byte = ser.read(1) - if not byte: - # Timeout - if buf: - # Return partial buffer if we have data - return buf.decode("utf-8", errors="ignore").strip() - return "" - if byte == b'\x00' or byte == b'\n' or byte == b'\r': - # End of frame - if buf: - return buf.decode("utf-8", errors="ignore").strip() - # Skip empty lines / consecutive terminators - continue - buf.append(byte[0]) - # Safety limit - if len(buf) > 256: - return buf.decode("utf-8", errors="ignore").strip() - - def _parse_line(self, line: str) -> dict[str, Any] | None: - """Parse a line from Arduino - TSV first, then JSON, fallback to regex. - - TSV format: 12.45\t0.02\t-0.01\t... (10 fields, per PROTOCOL.md) - JSON format: {"v":12.45,"rpm":4500,"eng":85,"gear":3} - Legacy text: V_bat: 12.45V - """ - # Try TSV first (new protocol) - if '\t' in line: - return self._parse_tsv(line) - - # Try JSON (may still be used for special messages) - try: - obj = json.loads(line) - return { - "voltage": obj.get("v"), - "rpm": obj.get("rpm"), - "eng_temp": obj.get("eng"), - "gear": obj.get("gear"), - } - except json.JSONDecodeError: - pass - - # Fallback to regex for legacy text protocol - result = {} - for key, pattern in self.PATTERNS.items(): - match = pattern.search(line) - if match: - val = match.group(1) - result[key] = float(val) if "." in val else int(val) - - return result if result else None - - def _parse_tsv(self, line: str) -> dict[str, Any] | None: - """Parse TSV telemetry frame per PROTOCOL.md. - - Fields: voltage, ax, ay, az, gx, gy, gz, roll, pitch, yaw - Empty fields (stale IMU) become NaN. - """ - fields = line.split('\t') - if len(fields) != len(self.TSV_FIELDS): - # Wrong field count - might be debug output or malformed - return None - - result = {} - for i, name in enumerate(self.TSV_FIELDS): - val_str = fields[i].strip() - if val_str == '': - # Empty field = stale/missing data - result[name] = float('nan') - else: - try: - result[name] = float(val_str) - except ValueError: - result[name] = float('nan') - - # IMU axis correction for mounting orientation - # Pitch/yaw inverted for motorcycle frame alignment (roll left as-is) - if 'pitch' in result and not math.isnan(result['pitch']): - result['pitch'] = -result['pitch'] - if 'yaw' in result and not math.isnan(result['yaw']): - result['yaw'] = -result['yaw'] - - return result - +"""Arduino service - connects to Arduino Nano via serial, buffers telemetry.""" + +import json +import math +import re +import threading +import time +from collections import deque +from typing import Any + +# pyserial for UART communication +try: + import serial +except ImportError: + serial = None # Allow import without pyserial for testing structure + + +class ArduinoService: + """Threaded Arduino serial reader with buffering and auto-reconnect.""" + + # TSV field names (order per PROTOCOL.md) + TSV_FIELDS = ['voltage', 'ax', 'ay', 'az', 'gx', 'gy', 'gz', 'roll', 'pitch', 'yaw', 'rpm', 'gear'] + + # Regex patterns for legacy text protocol (backwards compatibility) + PATTERNS = { + "voltage": re.compile(r"V_bat:\s*(\d+\.?\d*)V?", re.IGNORECASE), + "rpm": re.compile(r"RPM:\s*(\d+)", re.IGNORECASE), + "eng_temp": re.compile(r"ENG:\s*(\d+)C?", re.IGNORECASE), + "gear": re.compile(r"GEAR:\s*(\d+)", re.IGNORECASE), + } + + # ACK pattern: "ACK:CMD:STATUS" or "ACK:CMD:STATUS:extra" + ACK_PATTERN = re.compile(r"ACK:(\w+):(\w+)(?::(.*))?") + + def __init__( + self, + port: str = "/dev/serial0", + baudrate: int = 115200, + buffer_size: int = 100, + ): + self.port = port + self.baudrate = baudrate + self.buffer_size = buffer_size + + self._buffer: deque[dict[str, Any]] = deque(maxlen=buffer_size) + self._latest: dict[str, Any] = {} + self._connected = False + self._running = False + self._thread: threading.Thread | None = None + self._lock = threading.Lock() + + # Callbacks for push-based updates + self._on_data_callback = None + self._on_ack_callback = None + + # Serial port handle for sending commands + self._serial: Any = None + self._serial_lock = threading.Lock() + + # Periodic status logging + self._last_status_log = 0.0 + self._frame_count = 0 + + def set_on_data(self, callback): + """Set callback for new telemetry data. Called with data dict.""" + self._on_data_callback = callback + + def set_on_ack(self, callback): + """Set callback for ACK responses. Called with (cmd, status, extra).""" + self._on_ack_callback = callback + + def send_command(self, cmd: str, params: dict | None = None) -> bool: + """Send a command to Arduino via serial. + + Format: "CMD:NAME:PARAM1:PARAM2..." followed by newline + + Args: + cmd: Command name (e.g., "HORN", "LIGHT") + params: Optional parameters dict + + Returns: + True if sent successfully, False if serial unavailable + """ + with self._serial_lock: + if self._serial is None or not self._connected: + print(f"[Arduino] Cannot send command, not connected") + return False + + try: + # Build command string + parts = ["CMD", cmd.upper()] + if params: + for key, val in params.items(): + parts.append(f"{key}={val}") + line = ":".join(parts) + "\n" + + self._serial.write(line.encode("utf-8")) + self._serial.flush() + print(f"[Arduino] Sent: {line.strip()}") + return True + except Exception as e: + print(f"[Arduino] Failed to send command: {e}") + return False + + @property + def connected(self) -> bool: + return self._connected + + def get_latest(self) -> dict[str, Any]: + """Get most recent telemetry values.""" + with self._lock: + return self._latest.copy() if self._latest else {"error": "no data"} + + def get_buffer(self) -> list[dict[str, Any]]: + """Get buffered telemetry history.""" + with self._lock: + return list(self._buffer) + + def start(self): + """Start background serial reader thread.""" + if self._running: + return + self._running = True + self._thread = threading.Thread(target=self._reader_loop, daemon=True) + self._thread.start() + + def stop(self): + """Stop background reader.""" + self._running = False + if self._thread: + self._thread.join(timeout=2.0) + + def _reader_loop(self): + """Main reader loop with reconnection logic.""" + while self._running: + try: + self._connect_and_read() + except Exception as e: + self._connected = False + print(f"[Arduino] Connection error: {e}, retrying in 5s...") + time.sleep(5) + + def _connect_and_read(self): + """Connect to Arduino serial and read data.""" + if serial is None: + print("[Arduino] pyserial not installed, cannot connect") + return # Will retry via _reader_loop after 5s + + try: + ser = serial.Serial( + port=self.port, + baudrate=self.baudrate, + timeout=1.0, + ) + except serial.SerialException as e: + print(f"[Arduino] Cannot open {self.port}: {e}") + return # Will retry via _reader_loop after 5s + + try: + # Store serial handle for send_command() + with self._serial_lock: + self._serial = ser + self._connected = True + self._last_status_log = time.time() + self._frame_count = 0 + print(f"[Arduino] Connected to {self.port} @ {self.baudrate} baud") + + while self._running: + try: + # Read null-terminated line (TSV protocol) + line = self._read_null_terminated(ser) + if not line: + continue + + # Check for ACK responses first (legacy newline-terminated) + ack_match = self.ACK_PATTERN.match(line) + if ack_match: + cmd, status, extra = ack_match.groups() + if self._on_ack_callback: + self._on_ack_callback(cmd, status, extra) + continue + + data = self._parse_line(line) + if data: + data["time"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + with self._lock: + # Merge new values into latest (preserve old values for partial updates) + for key, val in data.items(): + if val is not None and not (isinstance(val, float) and math.isnan(val)): + self._latest[key] = val + self._latest["time"] = data["time"] + self._buffer.append(self._latest.copy()) + + # Invoke callback with new data + if self._on_data_callback: + self._on_data_callback(self._latest.copy()) + + # Periodic status log (every 5s) + self._frame_count += 1 + now = time.time() + if now - self._last_status_log >= 5.0: + elapsed = now - self._last_status_log + fps = self._frame_count / elapsed + v = self._latest.get('voltage', 0) + rpm = self._latest.get('rpm', 0) + gear = self._latest.get('gear', 0) + roll = self._latest.get('roll', 0) + print(f"[Arduino] {fps:.1f} fps | V={v:.1f} RPM={int(rpm)} G={int(gear)} roll={roll:.1f}°") + self._last_status_log = now + self._frame_count = 0 + + except serial.SerialException as e: + print(f"[Arduino] Serial error: {e}") + break + + finally: + self._connected = False + with self._serial_lock: + self._serial = None + ser.close() + + def _read_null_terminated(self, ser) -> str: + """Read bytes until null terminator or newline (fallback for legacy).""" + buf = bytearray() + while self._running: + byte = ser.read(1) + if not byte: + # Timeout + if buf: + # Return partial buffer if we have data + return buf.decode("utf-8", errors="ignore").strip() + return "" + if byte == b'\x00' or byte == b'\n' or byte == b'\r': + # End of frame + if buf: + return buf.decode("utf-8", errors="ignore").strip() + # Skip empty lines / consecutive terminators + continue + buf.append(byte[0]) + # Safety limit + if len(buf) > 256: + return buf.decode("utf-8", errors="ignore").strip() + + def _parse_line(self, line: str) -> dict[str, Any] | None: + """Parse a line from Arduino - TSV first, then JSON, fallback to regex. + + TSV format: 12.45\t0.02\t-0.01\t... (10 fields, per PROTOCOL.md) + JSON format: {"v":12.45,"rpm":4500,"eng":85,"gear":3} + Legacy text: V_bat: 12.45V + """ + # Try TSV first (new protocol) + if '\t' in line: + return self._parse_tsv(line) + + # Try JSON (may still be used for special messages) + try: + obj = json.loads(line) + return { + "voltage": obj.get("v"), + "rpm": obj.get("rpm"), + "eng_temp": obj.get("eng"), + "gear": obj.get("gear"), + } + except json.JSONDecodeError: + pass + + # Fallback to regex for legacy text protocol + result = {} + for key, pattern in self.PATTERNS.items(): + match = pattern.search(line) + if match: + val = match.group(1) + result[key] = float(val) if "." in val else int(val) + + return result if result else None + + def _parse_tsv(self, line: str) -> dict[str, Any] | None: + """Parse TSV telemetry frame per PROTOCOL.md. + + Fields: voltage, ax, ay, az, gx, gy, gz, roll, pitch, yaw + Empty fields (stale IMU) become NaN. + """ + fields = line.split('\t') + if len(fields) != len(self.TSV_FIELDS): + # Wrong field count - might be debug output or malformed + return None + + result = {} + for i, name in enumerate(self.TSV_FIELDS): + val_str = fields[i].strip() + if val_str == '': + # Empty field = stale/missing data + result[name] = float('nan') + else: + try: + result[name] = float(val_str) + except ValueError: + result[name] = float('nan') + + # IMU axis correction for mounting orientation + # Pitch/yaw inverted for motorcycle frame alignment (roll left as-is) + if 'pitch' in result and not math.isnan(result['pitch']): + result['pitch'] = -result['pitch'] + if 'yaw' in result and not math.isnan(result['yaw']): + result['yaw'] = -result['yaw'] + + return result + diff --git a/pi/backend/gps_service.py b/pi/backend/gps_service.py index 4bfe682..9c37e00 100644 --- a/pi/backend/gps_service.py +++ b/pi/backend/gps_service.py @@ -1,269 +1,305 @@ -"""GPS service - connects to gpsd, buffers data, handles reconnection.""" - -import random -import threading -import time -from collections import deque -from typing import Any - -# ============================================================================ -# DEBUG MODE - Set True for development without GPS hardware -# When True: skips gpsd entirely, generates realistic mock data -# When False: connects to real gpsd (requires GPS device) -# ============================================================================ -_GPS_DEBUG = True - -# gpsdclient is a modern, simple gpsd client -# Install gpsd on Pi: sudo apt install gpsd gpsd-clients -# Configure: sudo nano /etc/default/gpsd (set DEVICES="/dev/ttyUSB0" or similar) -try: - from gpsdclient import GPSDClient -except ImportError: - GPSDClient = None # Allow import without gpsd for testing structure - - -class GPSService: - """Threaded GPS reader with buffering and auto-reconnect.""" - - def __init__(self, host: str = "127.0.0.1", port: int = 2947, buffer_size: int = 100): - self.host = host - self.port = port - self.buffer_size = buffer_size - - self._buffer: deque[dict[str, Any]] = deque(maxlen=buffer_size) - self._latest: dict[str, Any] = {} - self._connected = False - self._running = False - self._thread: threading.Thread | None = None - self._lock = threading.Lock() - - # Callback for push-based updates - self._on_data_callback = None - - # Periodic status logging - self._last_status_log = 0.0 - self._fix_count = 0 - - def set_on_data(self, callback): - """Set callback for new GPS fix. Called with fix dict.""" - self._on_data_callback = callback - - @property - def connected(self) -> bool: - return self._connected - - def get_latest(self) -> dict[str, Any]: - """Get most recent GPS fix.""" - with self._lock: - return self._latest.copy() if self._latest else {"error": "no data"} - - def get_buffer(self) -> list[dict[str, Any]]: - """Get buffered GPS history.""" - with self._lock: - return list(self._buffer) - - def start(self): - """Start background GPS reader thread.""" - if self._running: - return - self._running = True - self._thread = threading.Thread(target=self._reader_loop, daemon=True) - self._thread.start() - print("[GPS] Service started") - - def stop(self): - """Stop background reader.""" - self._running = False - if self._thread: - self._thread.join(timeout=2.0) - - def _reader_loop(self): - """Main reader loop with reconnection logic.""" - print("[GPS] Reader thread running") - while self._running: - try: - self._connect_and_read() - except Exception as e: - self._connected = False - print(f"[GPS] Connection error: {e}, retrying in 5s...") - time.sleep(5) - - def _connect_and_read(self): - """Connect to gpsd and read data.""" - # Debug mode: skip gpsd entirely, use stub data - if _GPS_DEBUG: - print("[GPS] Debug mode enabled, using stub data") - self._stub_mode() - return - - if GPSDClient is None: - print("[GPS] gpsdclient not installed, running in stub mode") - self._stub_mode() - return - - # Quick check if gpsd is reachable before attempting connection - import socket - try: - sock = socket.create_connection((self.host, self.port), timeout=2.0) - sock.close() - except (socket.timeout, socket.error, OSError) as e: - print(f"[GPS] gpsd not reachable at {self.host}:{self.port}: {e}") - raise ConnectionError(f"gpsd not reachable: {e}") - - try: - client = GPSDClient(host=self.host, port=self.port) - except Exception as e: - print(f"[GPS] Cannot connect to gpsd at {self.host}:{self.port}: {e}") - raise ConnectionError(f"gpsd connection failed: {e}") - - with client: - self._connected = True - print(f"[GPS] Connected to gpsd at {self.host}:{self.port}") - - self._last_status_log = time.time() - self._fix_count = 0 - first_fix_timeout = time.time() + 5.0 # 5s to get first fix - - 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 - } - - # 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() > first_fix_timeout: - print("[GPS] No GPS fix after 5s, will retry connection") - raise ConnectionError("No GPS fix within timeout") - continue # Skip empty fixes - - # Got real data, disable timeout - first_fix_timeout = float('inf') - - 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: - - Normal 3D fix with satellites - - Occasional signal loss (~3% chance per second, lasts ~5s) - - 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 - - # 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() - - # Check for signal loss simulation - if signal_lost: - if now >= signal_lost_until: - signal_lost = False - print("[GPS] Signal recovered (stub)") - else: - # ~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))) - - 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), - } - - 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) +"""GPS service - connects to gpsd, buffers data, handles reconnection.""" + +import random +import threading +import time +from collections import deque +from typing import Any + +# ============================================================================ +# DEBUG MODE - Set True for development without GPS hardware +# When True: skips gpsd entirely, generates realistic mock data +# When False: connects to real gpsd (requires GPS device) +# ============================================================================ +_GPS_DEBUG = True + +# gpsdclient is a modern, simple gpsd client +# Install gpsd on Pi: sudo apt install gpsd gpsd-clients +# Configure: sudo nano /etc/default/gpsd (set DEVICES="/dev/ttyUSB0" or similar) +try: + from gpsdclient import GPSDClient +except ImportError: + GPSDClient = None # Allow import without gpsd for testing structure + + +class GPSService: + """Threaded GPS reader with buffering and auto-reconnect.""" + + def __init__(self, host: str = "127.0.0.1", port: int = 2947, buffer_size: int = 100): + self.host = host + self.port = port + self.buffer_size = buffer_size + + self._buffer: deque[dict[str, Any]] = deque(maxlen=buffer_size) + self._latest: dict[str, Any] = {} + self._connected = False + self._running = False + self._thread: threading.Thread | None = None + self._lock = threading.Lock() + + # Callback for push-based updates + self._on_data_callback = None + + # GPS state tracking (NMEA can't distinguish "acquiring" from "lost") + self._has_ever_fixed = False # True after first valid fix this session + + # Periodic status logging + self._last_status_log = 0.0 + self._fix_count = 0 + + def set_on_data(self, callback): + """Set callback for new GPS fix. Called with fix dict.""" + self._on_data_callback = callback + + @property + def connected(self) -> bool: + return self._connected + + def get_latest(self) -> dict[str, Any]: + """Get most recent GPS fix.""" + with self._lock: + return self._latest.copy() if self._latest else {"error": "no data"} + + def _gps_state(self, fix: dict) -> str: + """Determine GPS state: acquiring, fix, or lost. + + NMEA doesn't distinguish 'never had fix' from 'lost signal' — both + report mode 1 with no position. We track it ourselves. + """ + has_fix = fix.get("mode") in (2, 3) and fix.get("lat") is not None + if has_fix: + return "fix" + return "lost" if self._has_ever_fixed else "acquiring" + + def get_buffer(self) -> list[dict[str, Any]]: + """Get buffered GPS history.""" + with self._lock: + return list(self._buffer) + + def start(self): + """Start background GPS reader thread.""" + if self._running: + return + self._running = True + self._thread = threading.Thread(target=self._reader_loop, daemon=True) + self._thread.start() + print("[GPS] Service started") + + def stop(self): + """Stop background reader.""" + self._running = False + if self._thread: + self._thread.join(timeout=2.0) + + def _reader_loop(self): + """Main reader loop with reconnection logic.""" + print("[GPS] Reader thread running") + while self._running: + try: + self._connect_and_read() + except Exception as e: + self._connected = False + print(f"[GPS] Connection error: {e}, retrying in 5s...") + time.sleep(5) + + def _connect_and_read(self): + """Connect to gpsd and read data.""" + # Debug mode: skip gpsd entirely, use stub data + if _GPS_DEBUG: + print("[GPS] Debug mode enabled, using stub data") + self._stub_mode() + return + + if GPSDClient is None: + print("[GPS] gpsdclient not installed, running in stub mode") + self._stub_mode() + return + + # Quick check if gpsd is reachable before attempting connection + import socket + try: + sock = socket.create_connection((self.host, self.port), timeout=2.0) + sock.close() + except (socket.timeout, socket.error, OSError) as e: + print(f"[GPS] gpsd not reachable at {self.host}:{self.port}: {e}") + raise ConnectionError(f"gpsd not reachable: {e}") + + try: + client = GPSDClient(host=self.host, port=self.port) + except Exception as e: + print(f"[GPS] Cannot connect to gpsd at {self.host}:{self.port}: {e}") + raise ConnectionError(f"gpsd connection failed: {e}") + + with client: + self._connected = True + print(f"[GPS] Connected to gpsd at {self.host}:{self.port}") + + self._last_status_log = time.time() + self._fix_count = 0 + # 120s for initial cold fix, 10s for signal loss after first fix + fix_timeout = time.time() + 120.0 + + for result in client.dict_stream(filter=["TPV"]): + if not self._running: + break + + # TPV = Time-Position-Velocity report + fix = { + "time": result.get("time"), + "lat": result.get("lat"), + "lon": result.get("lon"), + "alt": result.get("alt"), + "speed": result.get("speed"), # m/s + "track": result.get("track"), # heading in degrees + "mode": result.get("mode"), # 0=no fix, 2=2D, 3=3D + "satellites": result.get("satellites"), # from SKY messages + } + + # Compute state and attach to fix + fix["gps_state"] = self._gps_state(fix) + + # Check if this is a real fix (has position) or just empty TPV + if fix.get("lat") is None and fix.get("mode") in (None, 0, 1): + # No real data yet, check timeout + if time.time() > fix_timeout: + timeout_s = "120s" if not self._has_ever_fixed else "10s" + print(f"[GPS] No GPS fix after {timeout_s}, will retry connection") + raise ConnectionError("No GPS fix within timeout") + 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 + 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 (~3s) + acquiring_until = time.time() + 3.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) diff --git a/pi/backend/main.py b/pi/backend/main.py index 609ceb7..d84056e 100644 --- a/pi/backend/main.py +++ b/pi/backend/main.py @@ -1,245 +1,247 @@ -"""Smart Serow Backend - GPS and Arduino services with HTTP API and WebSocket.""" - -from gevent import monkey -monkey.patch_all() # Must be at the very top before other imports - -from flask import Flask, jsonify -from flask_socketio import SocketIO, emit - -from gps_service import GPSService -from arduino_service import ArduinoService -from gpio_service import GPIOService -from throttle import Throttle - -app = Flask(__name__) -app.config["SECRET_KEY"] = "smartserow-secret" # Not security critical, just for session - -# SocketIO with gevent async mode (eventlet is deprecated) -socketio = SocketIO(app, async_mode="gevent", cors_allowed_origins="*") - -# Services -gps = GPSService() -arduino = ArduinoService() -gpio = GPIOService() - -# Throttles for emission rate limiting (20Hz for arduino, 1Hz for GPS) -arduino_throttle = Throttle(min_interval=0.05) # 20Hz max -gps_throttle = Throttle(min_interval=1.0) # 1Hz max - -# Track connected clients -connected_clients = set() - - -# ----------------------------------------------------------------------------- -# WebSocket Event Handlers -# ----------------------------------------------------------------------------- - -@socketio.on("connect") -def handle_connect(): - """Client connected.""" - client_id = id(socketio) # Simple identifier - connected_clients.add(client_id) - print(f"[WS] Client connected ({len(connected_clients)} total)") - - # Send current status immediately - emit("status", { - "gps_connected": gps.connected, - "arduino_connected": arduino.connected, - "theme_switch": gpio.theme_switch, - }) - - # Send latest data if available - arduino_data = arduino.get_latest() - if "error" not in arduino_data: - emit("arduino", arduino_data) - - gps_data = gps.get_latest() - if "error" not in gps_data: - emit("gps", gps_data) - - -@socketio.on("disconnect") -def handle_disconnect(): - """Client disconnected.""" - client_id = id(socketio) - connected_clients.discard(client_id) - print(f"[WS] Client disconnected ({len(connected_clients)} remaining)") - - -@socketio.on("button") -def handle_button(data): - """Handle button press from UI. - - Expected data: {"id": "horn", "action": "press", ...params} - """ - btn_id = data.get("id", "unknown") - action = data.get("action", "press") - params = {k: v for k, v in data.items() if k not in ("id", "action")} - - print(f"[WS] Button: {btn_id} {action} {params}") - - # Map button ID to Arduino command - cmd_map = { - "horn": "HORN", - "light": "LIGHT", - "indicator_left": "IND_L", - "indicator_right": "IND_R", - "hazard": "HAZARD", - } - - cmd = cmd_map.get(btn_id) - if cmd: - # Add action to params (e.g., ON/OFF based on press/release) - params["state"] = "ON" if action == "press" else "OFF" - success = arduino.send_command(cmd, params) - - # Send immediate ack for the attempt - emit("ack", { - "id": btn_id, - "status": "sent" if success else "failed", - "error": None if success else "arduino not connected", - }) - else: - emit("ack", { - "id": btn_id, - "status": "error", - "error": f"unknown button: {btn_id}", - }) - - -@socketio.on("emergency") -def handle_emergency(data): - """Handle emergency signal from UI.""" - etype = data.get("type", "stop") - print(f"[WS] EMERGENCY: {etype}") - - # Send emergency command to Arduino - arduino.send_command("EMERGENCY", {"type": etype}) - - # Broadcast alert to all clients - socketio.emit("alert", { - "type": "emergency", - "message": f"Emergency {etype} triggered", - }) - - -# ----------------------------------------------------------------------------- -# Service Callbacks (push data to WebSocket) -# ----------------------------------------------------------------------------- - -def on_arduino_data(data): - """Called by ArduinoService when new telemetry arrives.""" - # Always include current GPIO state (UI dedupes) - data = dict(data) # Don't mutate original - data["theme_switch"] = gpio.theme_switch - - def emit_fn(d): - socketio.emit("arduino", d) - - arduino_throttle.maybe_emit(data, emit_fn) - - -def on_gps_data(data): - """Called by GPSService when new fix arrives.""" - def emit_fn(d): - socketio.emit("gps", d) - - gps_throttle.maybe_emit(data, emit_fn) - - -def on_arduino_ack(cmd, status, extra): - """Called by ArduinoService when ACK received from Arduino.""" - socketio.emit("ack", { - "id": cmd.lower(), - "status": status.lower(), - "extra": extra, - }) - - -# ----------------------------------------------------------------------------- -# Background task to flush pending throttled data -# ----------------------------------------------------------------------------- - -def throttle_flusher(): - """Periodically flush pending throttled data.""" - import gevent - while True: - gevent.sleep(0.05) # 20Hz flush rate - - if arduino_throttle.has_pending: - arduino_throttle.flush(lambda d: socketio.emit("arduino", d)) - - if gps_throttle.has_pending: - gps_throttle.flush(lambda d: socketio.emit("gps", d)) - - -# ----------------------------------------------------------------------------- -# REST API (backward compatibility) -# ----------------------------------------------------------------------------- - -@app.route("/health") -def health(): - """Health check endpoint.""" - return jsonify({ - "status": "ok", - "gps_connected": gps.connected, - "arduino_connected": arduino.connected, - "ws_clients": len(connected_clients), - }) - - -@app.route("/gps") -def gps_data(): - """Current GPS data.""" - return jsonify(gps.get_latest()) - - -@app.route("/gps/history") -def gps_history(): - """Buffered GPS history.""" - return jsonify(gps.get_buffer()) - - -@app.route("/arduino") -def arduino_data(): - """Current Arduino telemetry (voltage, rpm, etc).""" - return jsonify(arduino.get_latest()) - - -@app.route("/arduino/history") -def arduino_history(): - """Buffered Arduino telemetry history.""" - return jsonify(arduino.get_buffer()) - - -# ----------------------------------------------------------------------------- -# Main Entry Point -# ----------------------------------------------------------------------------- - -def main(): - """Entry point.""" - # Wire up callbacks - arduino.set_on_data(on_arduino_data) - arduino.set_on_ack(on_arduino_ack) - gps.set_on_data(on_gps_data) - - # Start services - gps.start() - arduino.start() - gpio.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() - - -if __name__ == "__main__": - main() +"""Smart Serow Backend - GPS and Arduino services with HTTP API and WebSocket.""" + +from gevent import monkey +monkey.patch_all() # Must be at the very top before other imports + +from flask import Flask, jsonify +from flask_socketio import SocketIO, emit + +from gps_service import GPSService +from arduino_service import ArduinoService +from gpio_service import GPIOService +from throttle import Throttle + +app = Flask(__name__) +app.config["SECRET_KEY"] = "smartserow-secret" # Not security critical, just for session + +# SocketIO with gevent async mode (eventlet is deprecated) +socketio = SocketIO(app, async_mode="gevent", cors_allowed_origins="*") + +# Services +gps = GPSService() +arduino = ArduinoService() +gpio = GPIOService() + +# Throttles for emission rate limiting (20Hz for arduino, 1Hz for GPS) +arduino_throttle = Throttle(min_interval=0.05) # 20Hz max +gps_throttle = Throttle(min_interval=1.0) # 1Hz max + +# Track connected clients +connected_clients = set() + + +# ----------------------------------------------------------------------------- +# WebSocket Event Handlers +# ----------------------------------------------------------------------------- + +@socketio.on("connect") +def handle_connect(): + """Client connected.""" + client_id = id(socketio) # Simple identifier + connected_clients.add(client_id) + print(f"[WS] Client connected ({len(connected_clients)} total)") + + # Send current status immediately + emit("status", { + "gps_connected": gps.connected, + "arduino_connected": arduino.connected, + "theme_switch": gpio.theme_switch, + }) + + # Send latest data if available + arduino_data = arduino.get_latest() + if "error" not in arduino_data: + emit("arduino", arduino_data) + + gps_data = gps.get_latest() + if "error" not in gps_data: + emit("gps", gps_data) + + +@socketio.on("disconnect") +def handle_disconnect(): + """Client disconnected.""" + client_id = id(socketio) + connected_clients.discard(client_id) + print(f"[WS] Client disconnected ({len(connected_clients)} remaining)") + + +@socketio.on("button") +def handle_button(data): + """Handle button press from UI. + + Expected data: {"id": "horn", "action": "press", ...params} + """ + btn_id = data.get("id", "unknown") + action = data.get("action", "press") + params = {k: v for k, v in data.items() if k not in ("id", "action")} + + print(f"[WS] Button: {btn_id} {action} {params}") + + # Map button ID to Arduino command + cmd_map = { + "horn": "HORN", + "light": "LIGHT", + "indicator_left": "IND_L", + "indicator_right": "IND_R", + "hazard": "HAZARD", + } + + cmd = cmd_map.get(btn_id) + if cmd: + # Add action to params (e.g., ON/OFF based on press/release) + params["state"] = "ON" if action == "press" else "OFF" + success = arduino.send_command(cmd, params) + + # Send immediate ack for the attempt + emit("ack", { + "id": btn_id, + "status": "sent" if success else "failed", + "error": None if success else "arduino not connected", + }) + else: + emit("ack", { + "id": btn_id, + "status": "error", + "error": f"unknown button: {btn_id}", + }) + + +@socketio.on("emergency") +def handle_emergency(data): + """Handle emergency signal from UI.""" + etype = data.get("type", "stop") + print(f"[WS] EMERGENCY: {etype}") + + # Send emergency command to Arduino + arduino.send_command("EMERGENCY", {"type": etype}) + + # Broadcast alert to all clients + socketio.emit("alert", { + "type": "emergency", + "message": f"Emergency {etype} triggered", + }) + + +# ----------------------------------------------------------------------------- +# Service Callbacks (push data to WebSocket) +# ----------------------------------------------------------------------------- + +def on_arduino_data(data): + """Called by ArduinoService when new telemetry arrives.""" + # Always include current GPIO state (UI dedupes) + data = dict(data) # Don't mutate original + data["theme_switch"] = gpio.theme_switch + + def emit_fn(d): + socketio.emit("arduino", d) + + arduino_throttle.maybe_emit(data, emit_fn) + + +def on_gps_data(data): + """Called by GPSService when new fix arrives.""" + def emit_fn(d): + socketio.emit("gps", d) + + gps_throttle.maybe_emit(data, emit_fn) + + +def on_arduino_ack(cmd, status, extra): + """Called by ArduinoService when ACK received from Arduino.""" + socketio.emit("ack", { + "id": cmd.lower(), + "status": status.lower(), + "extra": extra, + }) + + +# ----------------------------------------------------------------------------- +# Background task to flush pending throttled data +# ----------------------------------------------------------------------------- + +def throttle_flusher(): + """Periodically flush pending throttled data.""" + import gevent + while True: + gevent.sleep(0.05) # 20Hz flush rate + + if arduino_throttle.has_pending: + arduino_throttle.flush(lambda d: socketio.emit("arduino", d)) + + if gps_throttle.has_pending: + gps_throttle.flush(lambda d: socketio.emit("gps", d)) + + +# ----------------------------------------------------------------------------- +# REST API (backward compatibility) +# ----------------------------------------------------------------------------- + +@app.route("/health") +def health(): + """Health check endpoint.""" + gps_latest = gps.get_latest() + return jsonify({ + "status": "ok", + "gps_connected": gps.connected, + "gps_state": gps_latest.get("gps_state", "acquiring"), + "arduino_connected": arduino.connected, + "ws_clients": len(connected_clients), + }) + + +@app.route("/gps") +def gps_data(): + """Current GPS data.""" + return jsonify(gps.get_latest()) + + +@app.route("/gps/history") +def gps_history(): + """Buffered GPS history.""" + return jsonify(gps.get_buffer()) + + +@app.route("/arduino") +def arduino_data(): + """Current Arduino telemetry (voltage, rpm, etc).""" + return jsonify(arduino.get_latest()) + + +@app.route("/arduino/history") +def arduino_history(): + """Buffered Arduino telemetry history.""" + return jsonify(arduino.get_buffer()) + + +# ----------------------------------------------------------------------------- +# Main Entry Point +# ----------------------------------------------------------------------------- + +def main(): + """Entry point.""" + # Wire up callbacks + arduino.set_on_data(on_arduino_data) + arduino.set_on_ack(on_arduino_ack) + gps.set_on_data(on_gps_data) + + # Start services + gps.start() + arduino.start() + gpio.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() + + +if __name__ == "__main__": + main() diff --git a/pi/backend/pyproject.toml b/pi/backend/pyproject.toml index 708c579..f55d99f 100644 --- a/pi/backend/pyproject.toml +++ b/pi/backend/pyproject.toml @@ -1,26 +1,26 @@ -[project] -name = "smartserow-backend" -version = "0.1.0" -description = "GPS and Arduino telemetry service for Smart Serow" -requires-python = ">=3.11" -dependencies = [ - "flask>=3.0", - "flask-socketio>=5.3.0", - "gevent>=24.0", - "gevent-websocket>=0.10", - "gpsdclient>=1.3", - "pyserial>=3.5", - # GPIO: install via apt (sudo apt install python3-rpi.gpio) - # Not listed here because pip versions require compilation -] - -[project.optional-dependencies] -dev = [ - "ruff", -] - -[project.scripts] -smartserow-backend = "main:main" - -[tool.ruff] -line-length = 100 +[project] +name = "smartserow-backend" +version = "0.1.0" +description = "GPS and Arduino telemetry service for Smart Serow" +requires-python = ">=3.11" +dependencies = [ + "flask>=3.0", + "flask-socketio>=5.3.0", + "gevent>=24.0", + "gevent-websocket>=0.10", + "gpsdclient>=1.3", + "pyserial>=3.5", + # GPIO: install via apt (sudo apt install python3-rpi.gpio) + # Not listed here because pip versions require compilation +] + +[project.optional-dependencies] +dev = [ + "ruff", +] + +[project.scripts] +smartserow-backend = "main:main" + +[tool.ruff] +line-length = 100 diff --git a/pi/backend/throttle.py b/pi/backend/throttle.py index 44b8dab..f57be96 100644 --- a/pi/backend/throttle.py +++ b/pi/backend/throttle.py @@ -1,61 +1,61 @@ -"""Throttle layer for rate-limiting telemetry emissions.""" - -import time -from typing import Any, Callable - - -class Throttle: - """Rate limiter for WebSocket emissions. - - Coalesces rapid updates - only emits at most once per min_interval. - If multiple updates arrive within the interval, the latest value wins. - """ - - def __init__(self, min_interval: float = 0.5): - """ - Args: - min_interval: Minimum seconds between emissions (default 0.5 = 2Hz max) - """ - self._last_emit: float = 0 - self._min_interval = min_interval - self._pending: Any = None - - def maybe_emit(self, data: Any, emit_fn: Callable[[Any], None]) -> bool: - """Emit if interval has passed, otherwise store as pending. - - Args: - data: Data to emit - emit_fn: Function to call with data when emitting - - Returns: - True if emitted, False if stored as pending - """ - now = time.time() - if now - self._last_emit >= self._min_interval: - emit_fn(data) - self._last_emit = now - self._pending = None - return True - else: - self._pending = data # Latest value wins - return False - - def flush(self, emit_fn: Callable[[Any], None]) -> bool: - """Emit pending data if any. - - Call this periodically to ensure pending data gets sent. - - Returns: - True if pending data was emitted, False if nothing pending - """ - if self._pending is not None: - emit_fn(self._pending) - self._last_emit = time.time() - self._pending = None - return True - return False - - @property - def has_pending(self) -> bool: - """Check if there's pending data waiting to be emitted.""" - return self._pending is not None +"""Throttle layer for rate-limiting telemetry emissions.""" + +import time +from typing import Any, Callable + + +class Throttle: + """Rate limiter for WebSocket emissions. + + Coalesces rapid updates - only emits at most once per min_interval. + If multiple updates arrive within the interval, the latest value wins. + """ + + def __init__(self, min_interval: float = 0.5): + """ + Args: + min_interval: Minimum seconds between emissions (default 0.5 = 2Hz max) + """ + self._last_emit: float = 0 + self._min_interval = min_interval + self._pending: Any = None + + def maybe_emit(self, data: Any, emit_fn: Callable[[Any], None]) -> bool: + """Emit if interval has passed, otherwise store as pending. + + Args: + data: Data to emit + emit_fn: Function to call with data when emitting + + Returns: + True if emitted, False if stored as pending + """ + now = time.time() + if now - self._last_emit >= self._min_interval: + emit_fn(data) + self._last_emit = now + self._pending = None + return True + else: + self._pending = data # Latest value wins + return False + + def flush(self, emit_fn: Callable[[Any], None]) -> bool: + """Emit pending data if any. + + Call this periodically to ensure pending data gets sent. + + Returns: + True if pending data was emitted, False if nothing pending + """ + if self._pending is not None: + emit_fn(self._pending) + self._last_emit = time.time() + self._pending = None + return True + return False + + @property + def has_pending(self) -> bool: + """Check if there's pending data waiting to be emitted.""" + return self._pending is not None diff --git a/pi/backend/utils/at_terminal.py b/pi/backend/utils/at_terminal.py new file mode 100644 index 0000000..a673b10 --- /dev/null +++ b/pi/backend/utils/at_terminal.py @@ -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() diff --git a/pi/ui/lib/app_root.dart b/pi/ui/lib/app_root.dart index bdb3ea5..c80ed7f 100644 --- a/pi/ui/lib/app_root.dart +++ b/pi/ui/lib/app_root.dart @@ -41,6 +41,7 @@ class _AppRootState extends State { // Show all items from the start so the row doesn't jump around _updateStatus('Config', '...'); _updateStatus('UART', '...'); + _updateStatus('GPS', '...'); _updateStatus('Navigator', '...'); // Config must load first (everything else depends on it) @@ -48,11 +49,13 @@ class _AppRootState extends State { await ConfigService.instance.load(); _updateStatus('Config', 'Ready'); - // UART health check and navigator image preload run truly in parallel + // UART, GPS, and navigator image preload run truly in parallel _updateStatus('UART', 'Connecting'); + _updateStatus('GPS', 'Waiting'); _updateStatus('Navigator', 'Loading'); await Future.wait([ _waitForUart(), + _waitForGps(), _preloadNavigatorImages(), ]); @@ -100,6 +103,39 @@ class _AppRootState extends State { _updateStatus('UART', 'Timeout'); } + /// Poll backend health endpoint until GPS has a fix, or bail after 7.5s + Future _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; + 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. diff --git a/pi/ui/lib/screens/dashboard_screen.dart b/pi/ui/lib/screens/dashboard_screen.dart index 840766b..14db07b 100644 --- a/pi/ui/lib/screens/dashboard_screen.dart +++ b/pi/ui/lib/screens/dashboard_screen.dart @@ -1,282 +1,286 @@ -import 'dart:async'; -import 'dart:math' show sqrt, sin, cos, pi; -import 'package:flutter/material.dart'; - -import '../services/backend_service.dart'; -import '../services/websocket_service.dart'; -import '../services/pi_io.dart'; -import '../theme/app_theme.dart'; -import '../widgets/navigator_widget.dart'; -import '../widgets/stat_box.dart'; -import '../widgets/stat_box_main.dart'; -import '../widgets/system_bar.dart'; -import '../widgets/debug_console.dart'; -import '../widgets/whiskey_mark.dart'; -import '../widgets/accel_graph.dart'; -import '../widgets/gps_compass.dart'; - -// test service for triggers -import '../services/test_flipflop_service.dart'; - -/// Main dashboard - displays Pi vitals and placeholder stats -class DashboardScreen extends StatefulWidget { - const DashboardScreen({super.key}); - - @override - State createState() => _DashboardScreenState(); -} - -class _DashboardScreenState extends State { - static const _surpriseThreshold = 0.24; // G threshold for navigator surprise - - final _navigatorKey = GlobalKey(); - - // Timer for Pi temp only (safety critical, direct file read) - Timer? _piTempTimer; - - // WebSocket stream subscriptions - StreamSubscription? _arduinoSub; - StreamSubscription? _gpsSub; - StreamSubscription? _connectionSub; - - // Pi temperature - direct file read (safety critical) - double? _piTemp; - - // From backend - Arduino data - int? _rpm; - double? _voltage; - int? _engineTemp; - int? _gear; - double? _roll; - double? _pitch; - double? _ax; - double? _ay; - double? _dynamicAx; // Gravity-compensated - double? _dynamicAy; - - // From backend - GPS data - double? _gpsSpeed; - double? _gpsTrack; - - // Placeholder values for system bar - int? _gpsSatellites; - int? _lteSignal; - - // WebSocket connection state - WsConnectionState _wsState = WsConnectionState.disconnected; - - @override - void initState() { - super.initState(); - - // Connect to WebSocket - WebSocketService.instance.connect(); - - // Subscribe to Arduino data stream - _arduinoSub = WebSocketService.instance.arduinoStream.listen((data) { - // Gravity-compensated acceleration - // When tilted, gravity "leaks" into horizontal axes - subtract it out - final rollRad = (data.roll ?? 0) * pi / 180; - final pitchRad = (data.pitch ?? 0) * pi / 180; - - // Subtract gravity leakage from measured acceleration - // Axes swapped for IMU mounting orientation - final dynamicAx = (data.ay ?? 0) + sin(rollRad); - final dynamicAy = (data.ax ?? 0) - (sin(pitchRad) * cos(rollRad)); - - setState(() { - _voltage = data.voltage; - _rpm = data.rpm; - _engineTemp = data.engTemp; - _gear = data.gear; - _roll = data.roll; - _pitch = data.pitch; - _ax = data.ax; - _ay = data.ay; - _dynamicAx = dynamicAx; - _dynamicAy = dynamicAy; - }); - - final gMagnitude = sqrt(dynamicAx * dynamicAx + dynamicAy * dynamicAy); - if (gMagnitude > _surpriseThreshold) { - _navigatorKey.currentState?.setEmotion('surprise'); - } - }); - - // Subscribe to GPS data stream - _gpsSub = WebSocketService.instance.gpsStream.listen((data) { - setState(() { - _gpsSpeed = data.speed; - _gpsTrack = data.track; - _gpsSatellites = data.satellites; - }); - }); - - // Subscribe to connection state - _connectionSub = WebSocketService.instance.connectionStream.listen((state) { - setState(() { - _wsState = state; - }); - }); - - // Timer for Pi temp only (safety critical - bypasses backend) - _piTempTimer = Timer.periodic(const Duration(milliseconds: 500), (_) { - setState(() { - _piTemp = PiIO.instance.getTemperature(); - }); - }); - - // Initialize with any cached data from WebSocketService - final cachedArduino = WebSocketService.instance.latestArduino; - if (cachedArduino != null) { - _voltage = cachedArduino.voltage; - _rpm = cachedArduino.rpm; - _engineTemp = cachedArduino.engTemp; - _gear = cachedArduino.gear; - _roll = cachedArduino.roll; - _pitch = cachedArduino.pitch; - _ax = cachedArduino.ax; - _ay = cachedArduino.ay; - } - - final cachedGps = WebSocketService.instance.latestGps; - if (cachedGps != null) { - _gpsSpeed = cachedGps.speed; - _gpsTrack = cachedGps.track; - _gpsSatellites = cachedGps.satellites; - } - - _wsState = WebSocketService.instance.connectionState; - - // Placeholder: LTE signal (TODO: wire up when LTE service exists) - _lteSignal = null; - - // DEBUG: flip-flop theme + navigator every 2s - TestFlipFlopService.instance.start(navigatorKey: _navigatorKey); - } - - @override - void dispose() { - _piTempTimer?.cancel(); - _arduinoSub?.cancel(); - _gpsSub?.cancel(); - _connectionSub?.cancel(); - TestFlipFlopService.instance.stop(); - super.dispose(); - } - - /// Format gear for display: null → "—", 0 → "N", 1-6 → "1"-"6" - String _formatGear(int? gear) { - if (gear == null) return '—'; - if (gear == 0) return 'N'; - return gear.toString(); - } - - /// Format nullable int for display - String _formatInt(int? value) => value?.toString() ?? '—'; - - /// Format nullable double for display with decimal places - String _formatDouble(double? value, [int decimals = 1]) { - if (value == null) return '—'; - return value.toStringAsFixed(decimals); - } - - @override - Widget build(BuildContext context) { - final theme = AppTheme.of(context); - - return Scaffold( - backgroundColor: theme.background, - body: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - // Left side: All dashboard widgets (flex: 2) - Expanded( - flex: 2, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // System status bar - SystemBar( - gpsSatellites: _gpsSatellites, - lteSignal: _lteSignal, - piTemp: _piTemp, - voltage: _voltage, - wsState: _wsState, - ), - - // Main content area - big widgets - Expanded( - flex: 7, - child: Row( - children: [ - // Attitude indicator (whiskey mark) - Expanded( - child: WhiskeyMark( - roll: _roll, - pitch: _pitch, - ), - ), - Expanded( - child: AccelGraph( - ax: _dynamicAx, // Gravity-compensated lateral - ay: _dynamicAy, // Gravity-compensated longitudinal - maxG: 0.8, - ghostTrackPeriod: const Duration(seconds: 4), - ), - ) - ], - ), - ), - - // Bottom stats row - Expanded( - flex: 3, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - StatBox(value: _formatInt(_rpm), label: 'RPM', isWarning: () => (_rpm ?? 0) > 4000), - GpsCompass(heading: _gpsTrack), - StatBox(value: _formatGear(_gear), label: 'GEAR'), - ], - ), - ), - ], - ), - ), - - const SizedBox(width: 32), - - // Right side: Navigator on top, debug console below - Expanded( - flex: 1, - child: Column( - children: [ - // Navigator - Expanded( - flex: 3, - child: Center( - child: NavigatorWidget(key: _navigatorKey), - ), - ), - // Debug console - Expanded( - flex: 1, - child: - DebugConsole( - messageStream: WebSocketService.instance.debugStream, - initialMessages: WebSocketService.instance.debugMessages, - maxLines: 6, - title: 'WebSocket messages', - ), - ), - ], - ), - ), - ], - ), - ), - ); - } -} +import 'dart:async'; +import 'dart:math' show sqrt, sin, cos, pi; +import 'package:flutter/material.dart'; + +import '../services/backend_service.dart'; +import '../services/websocket_service.dart'; +import '../services/pi_io.dart'; +import '../theme/app_theme.dart'; +import '../widgets/navigator_widget.dart'; +import '../widgets/stat_box.dart'; +import '../widgets/stat_box_main.dart'; +import '../widgets/system_bar.dart'; +import '../widgets/debug_console.dart'; +import '../widgets/whiskey_mark.dart'; +import '../widgets/accel_graph.dart'; +import '../widgets/gps_compass.dart'; + +// test service for triggers +import '../services/test_flipflop_service.dart'; + +/// Main dashboard - displays Pi vitals and placeholder stats +class DashboardScreen extends StatefulWidget { + const DashboardScreen({super.key}); + + @override + State createState() => _DashboardScreenState(); +} + +class _DashboardScreenState extends State { + static const _surpriseThreshold = 0.24; // G threshold for navigator surprise + + final _navigatorKey = GlobalKey(); + + // Timer for Pi temp only (safety critical, direct file read) + Timer? _piTempTimer; + + // WebSocket stream subscriptions + StreamSubscription? _arduinoSub; + StreamSubscription? _gpsSub; + StreamSubscription? _connectionSub; + + // Pi temperature - direct file read (safety critical) + double? _piTemp; + + // From backend - Arduino data + int? _rpm; + double? _voltage; + int? _engineTemp; + int? _gear; + double? _roll; + double? _pitch; + double? _ax; + double? _ay; + double? _dynamicAx; // Gravity-compensated + double? _dynamicAy; + + // From backend - GPS data + double? _gpsSpeed; + double? _gpsTrack; + + // Placeholder values for system bar + int? _gpsSatellites; + String? _gpsState; + int? _lteSignal; + + // WebSocket connection state + WsConnectionState _wsState = WsConnectionState.disconnected; + + @override + void initState() { + super.initState(); + + // Connect to WebSocket + WebSocketService.instance.connect(); + + // Subscribe to Arduino data stream + _arduinoSub = WebSocketService.instance.arduinoStream.listen((data) { + // Gravity-compensated acceleration + // When tilted, gravity "leaks" into horizontal axes - subtract it out + final rollRad = (data.roll ?? 0) * pi / 180; + final pitchRad = (data.pitch ?? 0) * pi / 180; + + // Subtract gravity leakage from measured acceleration + // Axes swapped for IMU mounting orientation + final dynamicAx = (data.ay ?? 0) + sin(rollRad); + final dynamicAy = (data.ax ?? 0) - (sin(pitchRad) * cos(rollRad)); + + setState(() { + _voltage = data.voltage; + _rpm = data.rpm; + _engineTemp = data.engTemp; + _gear = data.gear; + _roll = data.roll; + _pitch = data.pitch; + _ax = data.ax; + _ay = data.ay; + _dynamicAx = dynamicAx; + _dynamicAy = dynamicAy; + }); + + final gMagnitude = sqrt(dynamicAx * dynamicAx + dynamicAy * dynamicAy); + if (gMagnitude > _surpriseThreshold) { + _navigatorKey.currentState?.setEmotion('surprise'); + } + }); + + // Subscribe to GPS data stream + _gpsSub = WebSocketService.instance.gpsStream.listen((data) { + setState(() { + _gpsSpeed = data.speed; + _gpsTrack = data.track; + _gpsSatellites = data.satellites; + _gpsState = data.gpsState; + }); + }); + + // Subscribe to connection state + _connectionSub = WebSocketService.instance.connectionStream.listen((state) { + setState(() { + _wsState = state; + }); + }); + + // Timer for Pi temp only (safety critical - bypasses backend) + _piTempTimer = Timer.periodic(const Duration(milliseconds: 500), (_) { + setState(() { + _piTemp = PiIO.instance.getTemperature(); + }); + }); + + // Initialize with any cached data from WebSocketService + final cachedArduino = WebSocketService.instance.latestArduino; + if (cachedArduino != null) { + _voltage = cachedArduino.voltage; + _rpm = cachedArduino.rpm; + _engineTemp = cachedArduino.engTemp; + _gear = cachedArduino.gear; + _roll = cachedArduino.roll; + _pitch = cachedArduino.pitch; + _ax = cachedArduino.ax; + _ay = cachedArduino.ay; + } + + final cachedGps = WebSocketService.instance.latestGps; + if (cachedGps != null) { + _gpsSpeed = cachedGps.speed; + _gpsTrack = cachedGps.track; + _gpsSatellites = cachedGps.satellites; + _gpsState = cachedGps.gpsState; + } + + _wsState = WebSocketService.instance.connectionState; + + // Placeholder: LTE signal (TODO: wire up when LTE service exists) + _lteSignal = null; + + // DEBUG: flip-flop theme + navigator every 2s + TestFlipFlopService.instance.start(navigatorKey: _navigatorKey); + } + + @override + void dispose() { + _piTempTimer?.cancel(); + _arduinoSub?.cancel(); + _gpsSub?.cancel(); + _connectionSub?.cancel(); + TestFlipFlopService.instance.stop(); + super.dispose(); + } + + /// Format gear for display: null → "—", 0 → "N", 1-6 → "1"-"6" + String _formatGear(int? gear) { + if (gear == null) return '—'; + if (gear == 0) return 'N'; + return gear.toString(); + } + + /// Format nullable int for display + String _formatInt(int? value) => value?.toString() ?? '—'; + + /// Format nullable double for display with decimal places + String _formatDouble(double? value, [int decimals = 1]) { + if (value == null) return '—'; + return value.toStringAsFixed(decimals); + } + + @override + Widget build(BuildContext context) { + final theme = AppTheme.of(context); + + return Scaffold( + backgroundColor: theme.background, + body: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + // Left side: All dashboard widgets (flex: 2) + Expanded( + flex: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // System status bar + SystemBar( + gpsSatellites: _gpsSatellites, + gpsState: _gpsState, + lteSignal: _lteSignal, + piTemp: _piTemp, + voltage: _voltage, + wsState: _wsState, + ), + + // Main content area - big widgets + Expanded( + flex: 7, + child: Row( + children: [ + // Attitude indicator (whiskey mark) + Expanded( + child: WhiskeyMark( + roll: _roll, + pitch: _pitch, + ), + ), + Expanded( + child: AccelGraph( + ax: _dynamicAx, // Gravity-compensated lateral + ay: _dynamicAy, // Gravity-compensated longitudinal + maxG: 0.8, + ghostTrackPeriod: const Duration(seconds: 4), + ), + ) + ], + ), + ), + + // Bottom stats row + Expanded( + flex: 3, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + StatBox(value: _formatInt(_rpm), label: 'RPM', isWarning: () => (_rpm ?? 0) > 4000), + GpsCompass(heading: _gpsTrack, gpsState: _gpsState), + StatBox(value: _formatGear(_gear), label: 'GEAR'), + ], + ), + ), + ], + ), + ), + + const SizedBox(width: 32), + + // Right side: Navigator on top, debug console below + Expanded( + flex: 1, + child: Column( + children: [ + // Navigator + Expanded( + flex: 3, + child: Center( + child: NavigatorWidget(key: _navigatorKey), + ), + ), + // Debug console + Expanded( + flex: 1, + child: + DebugConsole( + messageStream: WebSocketService.instance.debugStream, + initialMessages: WebSocketService.instance.debugMessages, + maxLines: 6, + title: 'WebSocket messages', + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/pi/ui/lib/services/backend_service.dart b/pi/ui/lib/services/backend_service.dart index 116b6cb..f312de5 100644 --- a/pi/ui/lib/services/backend_service.dart +++ b/pi/ui/lib/services/backend_service.dart @@ -1,161 +1,163 @@ -import 'dart:async'; -import 'dart:convert'; -import 'package:http/http.dart' as http; - -/// Data from Arduino (voltage, rpm, engine temp, gear, IMU) -class ArduinoData { - final double? voltage; - final int? rpm; - final int? engTemp; - final int? gear; // 0 = neutral, 1-6 = gear - final double? roll; // Euler angle in degrees (negative = left, positive = right) - final double? pitch; // Euler angle in degrees (negative = nose down) - final double? ax; // Lateral acceleration (g) - final double? ay; // Longitudinal acceleration (g) - final double? az; // Vertical acceleration (g) - - ArduinoData({this.voltage, this.rpm, this.engTemp, this.gear, this.roll, this.pitch, this.ax, this.ay, this.az}); - - factory ArduinoData.fromJson(Map json) { - return ArduinoData( - voltage: (json['voltage'] as num?)?.toDouble(), - rpm: (json['rpm'] as num?)?.toInt(), - engTemp: (json['eng_temp'] as num?)?.toInt(), - gear: (json['gear'] as num?)?.toInt(), - roll: (json['roll'] as num?)?.toDouble(), // IMU mounted with axes swapped - pitch: (json['pitch'] as num?)?.toDouble(), - ax: (json['ax'] as num?)?.toDouble(), - ay: (json['ay'] as num?)?.toDouble(), - az: (json['az'] as num?)?.toDouble(), - ); - } -} - -/// Data from GPS -class GpsData { - final double? lat; - final double? lon; - final double? speed; // m/s - final double? alt; - final double? track; - final int? mode; // 0=no fix, 2=2D, 3=3D - final int? satellites; - - GpsData({this.lat, this.lon, this.speed, this.alt, this.track, this.mode, this.satellites}); - - factory GpsData.fromJson(Map json) { - return GpsData( - lat: (json['lat'] as num?)?.toDouble(), - lon: (json['lon'] as num?)?.toDouble(), - speed: (json['speed'] as num?)?.toDouble(), - alt: (json['alt'] as num?)?.toDouble(), - track: (json['track'] as num?)?.toDouble(), - mode: (json['mode'] as num?)?.toInt(), - satellites: (json['satellites'] as num?)?.toInt(), - ); - } -} - -/// HTTP client for Flask backend - fire-and-forget async fetch, sync cache return -/// -/// Follows the same pattern as PiIO: never blocks UI, always returns cached data. -class BackendService { - BackendService._() { - // Kick off initial fetches - _refreshArduino(); - _refreshGps(); - } - static final instance = BackendService._(); - - static const _baseUrl = 'http://127.0.0.1:5000'; - static const _timeout = Duration(seconds: 2); - - // Caches - ArduinoData? _arduinoCache; - GpsData? _gpsCache; - bool _connected = false; - - // In-progress flags (prevent duplicate requests) - bool _arduinoFetchInProgress = false; - bool _gpsFetchInProgress = false; - - /// Whether backend is reachable - bool get isConnected => _connected; - - /// Get Arduino data (sync, returns cached value) - ArduinoData? getArduinoData() { - if (!_arduinoFetchInProgress) { - _refreshArduino(); - } - return _arduinoCache; - } - - /// Get GPS data (sync, returns cached value) - GpsData? getGpsData() { - if (!_gpsFetchInProgress) { - _refreshGps(); - } - return _gpsCache; - } - - /// Background fetch for Arduino data - Future _refreshArduino() async { - if (_arduinoFetchInProgress) return; - _arduinoFetchInProgress = true; - - try { - final response = await http - .get(Uri.parse('$_baseUrl/arduino')) - .timeout(_timeout); - - if (response.statusCode == 200) { - final json = jsonDecode(response.body) as Map; - // Skip if backend returns error (no data yet) - keep cached value - if (!json.containsKey('error')) { - _arduinoCache = ArduinoData.fromJson(json); - } - _connected = true; - } - // Non-200: keep cached data, just mark disconnected - } catch (e) { - // Network error, timeout, etc - keep cached data for transient hiccups - _connected = false; - } finally { - _arduinoFetchInProgress = false; - } - } - - /// Background fetch for GPS data - Future _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; - // Skip if backend returns error (no data yet) - keep cached value - if (!json.containsKey('error')) { - _gpsCache = GpsData.fromJson(json); - } - _connected = true; - } - // Non-200: keep cached data, just mark disconnected - } catch (e) { - // Network error, timeout, etc - keep cached data for transient hiccups - _connected = false; - } finally { - _gpsFetchInProgress = false; - } - } - - /// Force clear all caches - void clearCache() { - _arduinoCache = null; - _gpsCache = null; - _connected = false; - } -} +import 'dart:async'; +import 'dart:convert'; +import 'package:http/http.dart' as http; + +/// Data from Arduino (voltage, rpm, engine temp, gear, IMU) +class ArduinoData { + final double? voltage; + final int? rpm; + final int? engTemp; + final int? gear; // 0 = neutral, 1-6 = gear + final double? roll; // Euler angle in degrees (negative = left, positive = right) + final double? pitch; // Euler angle in degrees (negative = nose down) + final double? ax; // Lateral acceleration (g) + final double? ay; // Longitudinal acceleration (g) + final double? az; // Vertical acceleration (g) + + ArduinoData({this.voltage, this.rpm, this.engTemp, this.gear, this.roll, this.pitch, this.ax, this.ay, this.az}); + + factory ArduinoData.fromJson(Map json) { + return ArduinoData( + voltage: (json['voltage'] as num?)?.toDouble(), + rpm: (json['rpm'] as num?)?.toInt(), + engTemp: (json['eng_temp'] as num?)?.toInt(), + gear: (json['gear'] as num?)?.toInt(), + roll: (json['roll'] as num?)?.toDouble(), // IMU mounted with axes swapped + pitch: (json['pitch'] as num?)?.toDouble(), + ax: (json['ax'] as num?)?.toDouble(), + ay: (json['ay'] as num?)?.toDouble(), + az: (json['az'] as num?)?.toDouble(), + ); + } +} + +/// Data from GPS +class GpsData { + final double? lat; + final double? lon; + final double? speed; // m/s + final double? alt; + final double? track; + final int? mode; // 0=no fix, 2=2D, 3=3D + final int? satellites; + final String? gpsState; // "acquiring", "fix", or "lost" + + GpsData({this.lat, this.lon, this.speed, this.alt, this.track, this.mode, this.satellites, this.gpsState}); + + factory GpsData.fromJson(Map json) { + return GpsData( + lat: (json['lat'] as num?)?.toDouble(), + lon: (json['lon'] as num?)?.toDouble(), + speed: (json['speed'] as num?)?.toDouble(), + alt: (json['alt'] as num?)?.toDouble(), + track: (json['track'] as num?)?.toDouble(), + mode: (json['mode'] as num?)?.toInt(), + satellites: (json['satellites'] as num?)?.toInt(), + gpsState: json['gps_state'] as String?, + ); + } +} + +/// HTTP client for Flask backend - fire-and-forget async fetch, sync cache return +/// +/// Follows the same pattern as PiIO: never blocks UI, always returns cached data. +class BackendService { + BackendService._() { + // Kick off initial fetches + _refreshArduino(); + _refreshGps(); + } + static final instance = BackendService._(); + + static const _baseUrl = 'http://127.0.0.1:5000'; + static const _timeout = Duration(seconds: 2); + + // Caches + ArduinoData? _arduinoCache; + GpsData? _gpsCache; + bool _connected = false; + + // In-progress flags (prevent duplicate requests) + bool _arduinoFetchInProgress = false; + bool _gpsFetchInProgress = false; + + /// Whether backend is reachable + bool get isConnected => _connected; + + /// Get Arduino data (sync, returns cached value) + ArduinoData? getArduinoData() { + if (!_arduinoFetchInProgress) { + _refreshArduino(); + } + return _arduinoCache; + } + + /// Get GPS data (sync, returns cached value) + GpsData? getGpsData() { + if (!_gpsFetchInProgress) { + _refreshGps(); + } + return _gpsCache; + } + + /// Background fetch for Arduino data + Future _refreshArduino() async { + if (_arduinoFetchInProgress) return; + _arduinoFetchInProgress = true; + + try { + final response = await http + .get(Uri.parse('$_baseUrl/arduino')) + .timeout(_timeout); + + if (response.statusCode == 200) { + final json = jsonDecode(response.body) as Map; + // Skip if backend returns error (no data yet) - keep cached value + if (!json.containsKey('error')) { + _arduinoCache = ArduinoData.fromJson(json); + } + _connected = true; + } + // Non-200: keep cached data, just mark disconnected + } catch (e) { + // Network error, timeout, etc - keep cached data for transient hiccups + _connected = false; + } finally { + _arduinoFetchInProgress = false; + } + } + + /// Background fetch for GPS data + Future _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; + // 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; + } +} diff --git a/pi/ui/lib/services/websocket_service.dart b/pi/ui/lib/services/websocket_service.dart index 8b1ccfa..ada5f80 100644 --- a/pi/ui/lib/services/websocket_service.dart +++ b/pi/ui/lib/services/websocket_service.dart @@ -1,332 +1,332 @@ -import 'dart:async'; -import 'package:socket_io_client/socket_io_client.dart' as io; - -import 'backend_service.dart'; // Reuse ArduinoData, GpsData -import 'theme_service.dart'; - -/// Connection state for WebSocket -enum WsConnectionState { - disconnected, - connecting, - connected, -} - -/// Acknowledgment from backend for a command -class CommandAck { - final String id; - final String status; - final String? error; - final String? extra; - - CommandAck({ - required this.id, - required this.status, - this.error, - this.extra, - }); - - bool get isSuccess => status == 'ok' || status == 'sent'; -} - -/// Alert from backend -class BackendAlert { - final String type; - final String message; - - BackendAlert({required this.type, required this.message}); -} - -/// Backend status (connection states of GPS/Arduino) -class BackendStatus { - final bool gpsConnected; - final bool arduinoConnected; - - BackendStatus({required this.gpsConnected, required this.arduinoConnected}); -} - -/// WebSocket service for real-time data from backend. -/// -/// Replaces HTTP polling with push-based updates. -/// Maintains dual logical channels: -/// - Telemetry: arduino/gps data streams (throttled by backend) -/// - Control: button commands and acknowledgments -class WebSocketService { - WebSocketService._() { - _setupStreams(); - } - static final instance = WebSocketService._(); - - static const _serverUrl = 'http://127.0.0.1:5000'; - - io.Socket? _socket; - WsConnectionState _connectionState = WsConnectionState.disconnected; - Timer? _reconnectTimer; - - // Latest values for sync access (backward compat) - ArduinoData? _latestArduino; - GpsData? _latestGps; - BackendStatus? _latestStatus; - - // Stream controllers - late StreamController _arduinoController; - late StreamController _gpsController; - late StreamController _statusController; - late StreamController _ackController; - late StreamController _alertController; - late StreamController _connectionController; - late StreamController _debugController; - - // Debug message buffer - static const int _maxDebugMessages = 50; - final List _debugMessages = []; - - void _setupStreams() { - _arduinoController = StreamController.broadcast(); - _gpsController = StreamController.broadcast(); - _statusController = StreamController.broadcast(); - _ackController = StreamController.broadcast(); - _alertController = StreamController.broadcast(); - _connectionController = StreamController.broadcast(); - _debugController = StreamController.broadcast(); - } - - /// Log a debug message (adds to buffer and stream) - void _log(String message) { - _debugMessages.add(message); - if (_debugMessages.length > _maxDebugMessages) { - _debugMessages.removeAt(0); - } - _debugController.add(message); - } - - // --- Public API: Streams --- - - /// Stream of Arduino telemetry updates - Stream get arduinoStream => _arduinoController.stream; - - /// Stream of GPS updates - Stream get gpsStream => _gpsController.stream; - - /// Stream of backend status updates - Stream get statusStream => _statusController.stream; - - /// Stream of command acknowledgments - Stream get ackStream => _ackController.stream; - - /// Stream of alerts from backend - Stream get alertStream => _alertController.stream; - - /// Stream of connection state changes - Stream get connectionStream => _connectionController.stream; - - /// Stream of debug log messages - Stream get debugStream => _debugController.stream; - - /// Current debug message buffer (for initial display) - List get debugMessages => List.unmodifiable(_debugMessages); - - // --- Public API: Sync getters (backward compat) --- - - /// Current connection state - WsConnectionState get connectionState => _connectionState; - - /// Whether connected to backend - bool get isConnected => _connectionState == WsConnectionState.connected; - - /// Latest Arduino data (may be null if not yet received) - ArduinoData? get latestArduino => _latestArduino; - - /// Latest GPS data (may be null if not yet received) - GpsData? get latestGps => _latestGps; - - /// Latest backend status - BackendStatus? get latestStatus => _latestStatus; - - // --- Public API: Connection --- - - /// Connect to backend WebSocket - void connect() { - if (_socket != null) return; // Already connected or connecting - - _setConnectionState(WsConnectionState.connecting); - - _socket = io.io(_serverUrl, { - 'transports': ['websocket'], - 'autoConnect': true, - 'reconnection': false, // We handle reconnection ourselves - }); - - _socket!.onConnect((_) { - _log('connected'); - _setConnectionState(WsConnectionState.connected); - _cancelReconnect(); - }); - - _socket!.onDisconnect((_) { - _log('disconnected'); - _setConnectionState(WsConnectionState.disconnected); - _scheduleReconnect(); - }); - - _socket!.onConnectError((error) { - _log('error: $error'); - _setConnectionState(WsConnectionState.disconnected); - _scheduleReconnect(); - }); - - _socket!.onError((error) { - _log('error: $error'); - }); - - // --- Telemetry Events --- - - _socket!.on('arduino', (data) { - if (data is Map) { - final arduino = ArduinoData.fromJson(data); - _latestArduino = arduino; - _arduinoController.add(arduino); - final rollStr = arduino.roll != null ? 'r${arduino.roll!.round()}' : ''; - final pitchStr = arduino.pitch != null ? 'p${arduino.pitch!.round()}' : ''; - final imuStr = (rollStr.isNotEmpty || pitchStr.isNotEmpty) ? ' $rollStr$pitchStr' : ''; - _log('ard: ${arduino.rpm ?? "-"}rpm ${arduino.voltage ?? "-"}V g${arduino.gear ?? "-"}$imuStr'); - - // Theme switch piggybacks on arduino packets (edge-triggered from backend) - if (data.containsKey('theme_switch')) { - final isDark = data['theme_switch'] as bool; - ThemeService.instance.setDarkMode(isDark); - _log('theme: ${isDark ? "dark" : "light"}'); - } - } - }); - - _socket!.on('gps', (data) { - if (data is Map) { - final gps = GpsData.fromJson(data); - _latestGps = gps; - _gpsController.add(gps); - _log('gps: ${gps.speed?.toStringAsFixed(1) ?? "-"}m/s hdg=${gps.track?.round() ?? "-"}° mode${gps.mode ?? "-"}'); - } - }); - - _socket!.on('status', (data) { - if (data is Map) { - final status = BackendStatus( - gpsConnected: data['gps_connected'] ?? false, - arduinoConnected: data['arduino_connected'] ?? false, - ); - _latestStatus = status; - _statusController.add(status); - _log('status: gps=${status.gpsConnected} ard=${status.arduinoConnected}'); - - // Initial theme state comes with status on connect - if (data.containsKey('theme_switch')) { - final isDark = data['theme_switch'] as bool; - ThemeService.instance.setDarkMode(isDark); - _log('theme: ${isDark ? "dark" : "light"} (initial)'); - } - } - }); - - // --- Control Events --- - - _socket!.on('ack', (data) { - if (data is Map) { - final ack = CommandAck( - id: data['id'] ?? 'unknown', - status: data['status'] ?? 'unknown', - error: data['error'], - extra: data['extra'], - ); - _ackController.add(ack); - _log('ack: ${ack.id}=${ack.status}${ack.error != null ? " err:${ack.error}" : ""}'); - } - }); - - _socket!.on('alert', (data) { - if (data is Map) { - final alert = BackendAlert( - type: data['type'] ?? 'unknown', - message: data['message'] ?? '', - ); - _alertController.add(alert); - _log('alert: [${alert.type}] ${alert.message}'); - } - }); - - _socket!.connect(); - } - - /// Disconnect from backend - void disconnect() { - _cancelReconnect(); - _socket?.disconnect(); - _socket?.dispose(); - _socket = null; - _setConnectionState(WsConnectionState.disconnected); - } - - // --- Public API: Commands --- - - /// Send button event to backend - void sendButton(String id, String action, [Map? params]) { - if (_socket == null || !isConnected) { - print('[WS] Cannot send button, not connected'); - return; - } - - final data = { - 'id': id, - 'action': action, - ...?params, - }; - - _socket!.emit('button', data); - } - - /// Send emergency signal to backend - void sendEmergency(String type) { - if (_socket == null) { - print('[WS] Cannot send emergency, not connected'); - return; - } - - // Emergency should be sent even if not fully connected - _socket!.emit('emergency', {'type': type}); - } - - // --- Private --- - - void _setConnectionState(WsConnectionState state) { - if (_connectionState != state) { - _connectionState = state; - _connectionController.add(state); - } - } - - void _scheduleReconnect() { - _cancelReconnect(); - _reconnectTimer = Timer(const Duration(seconds: 3), () { - print('[WS] Attempting reconnect...'); - _socket?.dispose(); - _socket = null; - connect(); - }); - } - - void _cancelReconnect() { - _reconnectTimer?.cancel(); - _reconnectTimer = null; - } - - /// Dispose all resources (call on app shutdown) - void dispose() { - disconnect(); - _arduinoController.close(); - _gpsController.close(); - _statusController.close(); - _ackController.close(); - _alertController.close(); - _connectionController.close(); - _debugController.close(); - } -} +import 'dart:async'; +import 'package:socket_io_client/socket_io_client.dart' as io; + +import 'backend_service.dart'; // Reuse ArduinoData, GpsData +import 'theme_service.dart'; + +/// Connection state for WebSocket +enum WsConnectionState { + disconnected, + connecting, + connected, +} + +/// Acknowledgment from backend for a command +class CommandAck { + final String id; + final String status; + final String? error; + final String? extra; + + CommandAck({ + required this.id, + required this.status, + this.error, + this.extra, + }); + + bool get isSuccess => status == 'ok' || status == 'sent'; +} + +/// Alert from backend +class BackendAlert { + final String type; + final String message; + + BackendAlert({required this.type, required this.message}); +} + +/// Backend status (connection states of GPS/Arduino) +class BackendStatus { + final bool gpsConnected; + final bool arduinoConnected; + + BackendStatus({required this.gpsConnected, required this.arduinoConnected}); +} + +/// WebSocket service for real-time data from backend. +/// +/// Replaces HTTP polling with push-based updates. +/// Maintains dual logical channels: +/// - Telemetry: arduino/gps data streams (throttled by backend) +/// - Control: button commands and acknowledgments +class WebSocketService { + WebSocketService._() { + _setupStreams(); + } + static final instance = WebSocketService._(); + + static const _serverUrl = 'http://127.0.0.1:5000'; + + io.Socket? _socket; + WsConnectionState _connectionState = WsConnectionState.disconnected; + Timer? _reconnectTimer; + + // Latest values for sync access (backward compat) + ArduinoData? _latestArduino; + GpsData? _latestGps; + BackendStatus? _latestStatus; + + // Stream controllers + late StreamController _arduinoController; + late StreamController _gpsController; + late StreamController _statusController; + late StreamController _ackController; + late StreamController _alertController; + late StreamController _connectionController; + late StreamController _debugController; + + // Debug message buffer + static const int _maxDebugMessages = 50; + final List _debugMessages = []; + + void _setupStreams() { + _arduinoController = StreamController.broadcast(); + _gpsController = StreamController.broadcast(); + _statusController = StreamController.broadcast(); + _ackController = StreamController.broadcast(); + _alertController = StreamController.broadcast(); + _connectionController = StreamController.broadcast(); + _debugController = StreamController.broadcast(); + } + + /// Log a debug message (adds to buffer and stream) + void _log(String message) { + _debugMessages.add(message); + if (_debugMessages.length > _maxDebugMessages) { + _debugMessages.removeAt(0); + } + _debugController.add(message); + } + + // --- Public API: Streams --- + + /// Stream of Arduino telemetry updates + Stream get arduinoStream => _arduinoController.stream; + + /// Stream of GPS updates + Stream get gpsStream => _gpsController.stream; + + /// Stream of backend status updates + Stream get statusStream => _statusController.stream; + + /// Stream of command acknowledgments + Stream get ackStream => _ackController.stream; + + /// Stream of alerts from backend + Stream get alertStream => _alertController.stream; + + /// Stream of connection state changes + Stream get connectionStream => _connectionController.stream; + + /// Stream of debug log messages + Stream get debugStream => _debugController.stream; + + /// Current debug message buffer (for initial display) + List get debugMessages => List.unmodifiable(_debugMessages); + + // --- Public API: Sync getters (backward compat) --- + + /// Current connection state + WsConnectionState get connectionState => _connectionState; + + /// Whether connected to backend + bool get isConnected => _connectionState == WsConnectionState.connected; + + /// Latest Arduino data (may be null if not yet received) + ArduinoData? get latestArduino => _latestArduino; + + /// Latest GPS data (may be null if not yet received) + GpsData? get latestGps => _latestGps; + + /// Latest backend status + BackendStatus? get latestStatus => _latestStatus; + + // --- Public API: Connection --- + + /// Connect to backend WebSocket + void connect() { + if (_socket != null) return; // Already connected or connecting + + _setConnectionState(WsConnectionState.connecting); + + _socket = io.io(_serverUrl, { + 'transports': ['websocket'], + 'autoConnect': true, + 'reconnection': false, // We handle reconnection ourselves + }); + + _socket!.onConnect((_) { + _log('connected'); + _setConnectionState(WsConnectionState.connected); + _cancelReconnect(); + }); + + _socket!.onDisconnect((_) { + _log('disconnected'); + _setConnectionState(WsConnectionState.disconnected); + _scheduleReconnect(); + }); + + _socket!.onConnectError((error) { + _log('error: $error'); + _setConnectionState(WsConnectionState.disconnected); + _scheduleReconnect(); + }); + + _socket!.onError((error) { + _log('error: $error'); + }); + + // --- Telemetry Events --- + + _socket!.on('arduino', (data) { + if (data is Map) { + final arduino = ArduinoData.fromJson(data); + _latestArduino = arduino; + _arduinoController.add(arduino); + final rollStr = arduino.roll != null ? 'r${arduino.roll!.round()}' : ''; + final pitchStr = arduino.pitch != null ? 'p${arduino.pitch!.round()}' : ''; + final imuStr = (rollStr.isNotEmpty || pitchStr.isNotEmpty) ? ' $rollStr$pitchStr' : ''; + _log('ard: ${arduino.rpm ?? "-"}rpm ${arduino.voltage ?? "-"}V g${arduino.gear ?? "-"}$imuStr'); + + // Theme switch piggybacks on arduino packets (edge-triggered from backend) + if (data.containsKey('theme_switch')) { + final isDark = data['theme_switch'] as bool; + ThemeService.instance.setDarkMode(isDark); + _log('theme: ${isDark ? "dark" : "light"}'); + } + } + }); + + _socket!.on('gps', (data) { + if (data is Map) { + final gps = GpsData.fromJson(data); + _latestGps = gps; + _gpsController.add(gps); + _log('gps: ${gps.speed?.toStringAsFixed(1) ?? "-"}m/s hdg=${gps.track?.round() ?? "-"}° mode${gps.mode ?? "-"}'); + } + }); + + _socket!.on('status', (data) { + if (data is Map) { + final status = BackendStatus( + gpsConnected: data['gps_connected'] ?? false, + arduinoConnected: data['arduino_connected'] ?? false, + ); + _latestStatus = status; + _statusController.add(status); + _log('status: gps=${status.gpsConnected} ard=${status.arduinoConnected}'); + + // Initial theme state comes with status on connect + if (data.containsKey('theme_switch')) { + final isDark = data['theme_switch'] as bool; + ThemeService.instance.setDarkMode(isDark); + _log('theme: ${isDark ? "dark" : "light"} (initial)'); + } + } + }); + + // --- Control Events --- + + _socket!.on('ack', (data) { + if (data is Map) { + final ack = CommandAck( + id: data['id'] ?? 'unknown', + status: data['status'] ?? 'unknown', + error: data['error'], + extra: data['extra'], + ); + _ackController.add(ack); + _log('ack: ${ack.id}=${ack.status}${ack.error != null ? " err:${ack.error}" : ""}'); + } + }); + + _socket!.on('alert', (data) { + if (data is Map) { + final alert = BackendAlert( + type: data['type'] ?? 'unknown', + message: data['message'] ?? '', + ); + _alertController.add(alert); + _log('alert: [${alert.type}] ${alert.message}'); + } + }); + + _socket!.connect(); + } + + /// Disconnect from backend + void disconnect() { + _cancelReconnect(); + _socket?.disconnect(); + _socket?.dispose(); + _socket = null; + _setConnectionState(WsConnectionState.disconnected); + } + + // --- Public API: Commands --- + + /// Send button event to backend + void sendButton(String id, String action, [Map? params]) { + if (_socket == null || !isConnected) { + print('[WS] Cannot send button, not connected'); + return; + } + + final data = { + 'id': id, + 'action': action, + ...?params, + }; + + _socket!.emit('button', data); + } + + /// Send emergency signal to backend + void sendEmergency(String type) { + if (_socket == null) { + print('[WS] Cannot send emergency, not connected'); + return; + } + + // Emergency should be sent even if not fully connected + _socket!.emit('emergency', {'type': type}); + } + + // --- Private --- + + void _setConnectionState(WsConnectionState state) { + if (_connectionState != state) { + _connectionState = state; + _connectionController.add(state); + } + } + + void _scheduleReconnect() { + _cancelReconnect(); + _reconnectTimer = Timer(const Duration(seconds: 3), () { + print('[WS] Attempting reconnect...'); + _socket?.dispose(); + _socket = null; + connect(); + }); + } + + void _cancelReconnect() { + _reconnectTimer?.cancel(); + _reconnectTimer = null; + } + + /// Dispose all resources (call on app shutdown) + void dispose() { + disconnect(); + _arduinoController.close(); + _gpsController.close(); + _statusController.close(); + _ackController.close(); + _alertController.close(); + _connectionController.close(); + _debugController.close(); + } +} diff --git a/pi/ui/lib/widgets/debug_console.dart b/pi/ui/lib/widgets/debug_console.dart index 26fa902..1c604ce 100644 --- a/pi/ui/lib/widgets/debug_console.dart +++ b/pi/ui/lib/widgets/debug_console.dart @@ -1,119 +1,119 @@ -import 'dart:async'; -import 'package:flutter/material.dart'; - -import '../theme/app_theme.dart'; - -/// Generic debug console that displays streaming log messages. -/// -/// Can be wired to any message source via [messageStream] and [initialMessages]. -/// Example sources: WebSocketService.debugStream, ArduinoService logs, etc. -class DebugConsole extends StatefulWidget { - /// Stream of new messages to display - final Stream messageStream; - - /// Initial messages to populate (e.g., from a buffer) - final List initialMessages; - - /// Maximum lines to display - final int maxLines; - - /// Optional title for the console (shown in title bar) - final String? title; - - const DebugConsole({ - super.key, - required this.messageStream, - this.initialMessages = const [], - this.maxLines = 8, - this.title, - }); - - @override - State createState() => _DebugConsoleState(); -} - -class _DebugConsoleState extends State { - final List _messages = []; - StreamSubscription? _sub; - - @override - void initState() { - super.initState(); - - // Initialize with existing buffer - _messages.addAll(widget.initialMessages); - _trimMessages(); - - // Subscribe to new messages - _sub = widget.messageStream.listen((msg) { - setState(() { - _messages.add(msg); - _trimMessages(); - }); - }); - } - - void _trimMessages() { - while (_messages.length > widget.maxLines) { - _messages.removeAt(0); - } - } - - @override - void dispose() { - _sub?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final theme = AppTheme.of(context); - - return Container( - decoration: BoxDecoration( - color: theme.background.withAlpha(64), - border: Border.all(color: theme.subdued, width: 2), - borderRadius: BorderRadius.circular(8), - ), - child: Column( - mainAxisSize: MainAxisSize.max, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Title bar (optional) - if (widget.title != null) - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide(color: theme.subdued, width: 1), - ), - ), - child: Text( - widget.title!, - style: TextStyle( - fontFamily: 'monospace', - fontSize: 24, - color: theme.subdued, - ), - ), - ), - // Console content - Expanded( - child: Padding( - padding: const EdgeInsets.all(8), - child: Text( - _messages.isEmpty ? '(no messages)' : _messages.join('\n'), - style: TextStyle( - fontFamily: 'monospace', - fontSize: 30, - color: theme.foreground, - height: 1.0, - ), - ), - ), - ), - ], - ), - ); - } -} +import 'dart:async'; +import 'package:flutter/material.dart'; + +import '../theme/app_theme.dart'; + +/// Generic debug console that displays streaming log messages. +/// +/// Can be wired to any message source via [messageStream] and [initialMessages]. +/// Example sources: WebSocketService.debugStream, ArduinoService logs, etc. +class DebugConsole extends StatefulWidget { + /// Stream of new messages to display + final Stream messageStream; + + /// Initial messages to populate (e.g., from a buffer) + final List initialMessages; + + /// Maximum lines to display + final int maxLines; + + /// Optional title for the console (shown in title bar) + final String? title; + + const DebugConsole({ + super.key, + required this.messageStream, + this.initialMessages = const [], + this.maxLines = 8, + this.title, + }); + + @override + State createState() => _DebugConsoleState(); +} + +class _DebugConsoleState extends State { + final List _messages = []; + StreamSubscription? _sub; + + @override + void initState() { + super.initState(); + + // Initialize with existing buffer + _messages.addAll(widget.initialMessages); + _trimMessages(); + + // Subscribe to new messages + _sub = widget.messageStream.listen((msg) { + setState(() { + _messages.add(msg); + _trimMessages(); + }); + }); + } + + void _trimMessages() { + while (_messages.length > widget.maxLines) { + _messages.removeAt(0); + } + } + + @override + void dispose() { + _sub?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = AppTheme.of(context); + + return Container( + decoration: BoxDecoration( + color: theme.background.withAlpha(64), + border: Border.all(color: theme.subdued, width: 2), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Title bar (optional) + if (widget.title != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: theme.subdued, width: 1), + ), + ), + child: Text( + widget.title!, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 24, + color: theme.subdued, + ), + ), + ), + // Console content + Expanded( + child: Padding( + padding: const EdgeInsets.all(8), + child: Text( + _messages.isEmpty ? '(no messages)' : _messages.join('\n'), + style: TextStyle( + fontFamily: 'monospace', + fontSize: 30, + color: theme.foreground, + height: 1.0, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/pi/ui/lib/widgets/gps_compass.dart b/pi/ui/lib/widgets/gps_compass.dart index baa83d4..a2b0460 100644 --- a/pi/ui/lib/widgets/gps_compass.dart +++ b/pi/ui/lib/widgets/gps_compass.dart @@ -4,14 +4,16 @@ import '../theme/app_theme.dart'; class GpsCompass extends StatelessWidget { final double? heading; + final String? gpsState; // "acquiring", "fix", "lost" - const GpsCompass({super.key, this.heading}); + 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'; // Just make it clear; redundant anyways, this only gets called when _hasSignal - return '${(heading! % 360).round()}'; // No need for the degree symbol + if (!_hasSignal) return 'N/A'; + return '${(heading! % 360).round()}'; } String get _compassDirection { @@ -56,7 +58,7 @@ class GpsCompass extends StatelessWidget { child: FittedBox( fit: BoxFit.contain, child: Text( - _hasSignal ? "${_displayHeading} ${_compassDirection}" : "N/A", + _hasSignal ? "${_displayHeading} ${_compassDirection}" : (_isAcquiring ? "ACQ" : "N/A"), style: TextStyle( fontSize: 80, color: theme.subdued, diff --git a/pi/ui/lib/widgets/navigator_widget.dart b/pi/ui/lib/widgets/navigator_widget.dart index b16e446..f607c13 100644 --- a/pi/ui/lib/widgets/navigator_widget.dart +++ b/pi/ui/lib/widgets/navigator_widget.dart @@ -1,100 +1,100 @@ -import 'dart:io'; -import 'dart:math'; -import 'package:flutter/material.dart'; -import '../services/config_service.dart'; - -/// Displays the navigator character with emotion support. -/// -/// Use a GlobalKey to control emotions from parent: -/// ```dart -/// final _navigatorKey = GlobalKey(); -/// NavigatorWidget(key: _navigatorKey) -/// // Later: -/// _navigatorKey.currentState?.setEmotion('happy'); -/// ``` -class NavigatorWidget extends StatefulWidget { - const NavigatorWidget({super.key}); - - @override - State createState() => NavigatorWidgetState(); -} - -class NavigatorWidgetState extends State - with SingleTickerProviderStateMixin { - String _emotion = 'default'; - late AnimationController _shakeController; - - @override - void initState() { - super.initState(); - _shakeController = AnimationController( - duration: const Duration(milliseconds: 400), - vsync: this, - ); - // Auto-reset to default after surprise animation completes - _shakeController.addStatusListener((status) { - if (status == AnimationStatus.completed && _emotion == 'surprise') { - setState(() => _emotion = 'default'); - } - }); - } - - @override - void dispose() { - _shakeController.dispose(); - super.dispose(); - } - - /// Change the displayed emotion. - /// Image file must exist at: {assetsPath}/navigator/{navigator}/{emotion}.png - void setEmotion(String emotion) { - if (emotion != _emotion) { - setState(() => _emotion = emotion); - if (emotion == 'surprise') { - _shakeController.forward(from: 0); - } - } - } - - /// Reset to default emotion - void reset() => setEmotion('default'); - - /// Current emotion - String get emotion => _emotion; - - @override - Widget build(BuildContext context) { - final config = ConfigService.instance; - final basePath = '${config.assetsPath}/navigator/${config.navigator}'; - - final image = Image.file( - File('$basePath/$_emotion.png'), - fit: BoxFit.contain, - errorBuilder: (context, error, stackTrace) { - // Fallback: try default.png if specific emotion missing - if (_emotion != 'default') { - return Image.file( - File('$basePath/default.png'), - fit: BoxFit.contain, - errorBuilder: (_, __, ___) => const SizedBox.shrink(), - ); - } - return const SizedBox.shrink(); - }, - ); - - // Shake animation for surprise - return AnimatedBuilder( - animation: _shakeController, - child: image, - builder: (context, child) { - final shake = sin(_shakeController.value * pi * 6) * 25 * - (1 - _shakeController.value); // 6 oscillations, 25px amplitude, decay - return Transform.translate( - offset: Offset(shake, 0), - child: child, - ); - }, - ); - } -} +import 'dart:io'; +import 'dart:math'; +import 'package:flutter/material.dart'; +import '../services/config_service.dart'; + +/// Displays the navigator character with emotion support. +/// +/// Use a GlobalKey to control emotions from parent: +/// ```dart +/// final _navigatorKey = GlobalKey(); +/// NavigatorWidget(key: _navigatorKey) +/// // Later: +/// _navigatorKey.currentState?.setEmotion('happy'); +/// ``` +class NavigatorWidget extends StatefulWidget { + const NavigatorWidget({super.key}); + + @override + State createState() => NavigatorWidgetState(); +} + +class NavigatorWidgetState extends State + with SingleTickerProviderStateMixin { + String _emotion = 'default'; + late AnimationController _shakeController; + + @override + void initState() { + super.initState(); + _shakeController = AnimationController( + duration: const Duration(milliseconds: 400), + vsync: this, + ); + // Auto-reset to default after surprise animation completes + _shakeController.addStatusListener((status) { + if (status == AnimationStatus.completed && _emotion == 'surprise') { + setState(() => _emotion = 'default'); + } + }); + } + + @override + void dispose() { + _shakeController.dispose(); + super.dispose(); + } + + /// Change the displayed emotion. + /// Image file must exist at: {assetsPath}/navigator/{navigator}/{emotion}.png + void setEmotion(String emotion) { + if (emotion != _emotion) { + setState(() => _emotion = emotion); + if (emotion == 'surprise') { + _shakeController.forward(from: 0); + } + } + } + + /// Reset to default emotion + void reset() => setEmotion('default'); + + /// Current emotion + String get emotion => _emotion; + + @override + Widget build(BuildContext context) { + final config = ConfigService.instance; + final basePath = '${config.assetsPath}/navigator/${config.navigator}'; + + final image = Image.file( + File('$basePath/$_emotion.png'), + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + // Fallback: try default.png if specific emotion missing + if (_emotion != 'default') { + return Image.file( + File('$basePath/default.png'), + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => const SizedBox.shrink(), + ); + } + return const SizedBox.shrink(); + }, + ); + + // Shake animation for surprise + return AnimatedBuilder( + animation: _shakeController, + child: image, + builder: (context, child) { + final shake = sin(_shakeController.value * pi * 6) * 25 * + (1 - _shakeController.value); // 6 oscillations, 25px amplitude, decay + return Transform.translate( + offset: Offset(shake, 0), + child: child, + ); + }, + ); + } +} diff --git a/pi/ui/lib/widgets/system_bar.dart b/pi/ui/lib/widgets/system_bar.dart index b7ad836..6344fb0 100644 --- a/pi/ui/lib/widgets/system_bar.dart +++ b/pi/ui/lib/widgets/system_bar.dart @@ -1,170 +1,174 @@ -import 'package:flutter/material.dart'; - -import '../services/websocket_service.dart'; -import '../theme/app_theme.dart'; - -/// Android-style persistent status bar for system indicators. -/// Shows GPS satellites, LTE signal, Pi temp, voltage, WS status at a glance. -class SystemBar extends StatelessWidget { - final int? gpsSatellites; // null = disconnected - final int? lteSignal; // null = disconnected, 0-4 bars - final double? piTemp; // null = unavailable - final double? voltage; // null = Arduino disconnected - final WsConnectionState? wsState; // WebSocket connection state - - const SystemBar({ - super.key, - this.gpsSatellites, - this.lteSignal, - this.piTemp, - this.voltage, - this.wsState, - }); - - /// Get WebSocket status text and abnormal flag - (String, bool) _wsStatus() { - switch (wsState) { - case WsConnectionState.connected: - return ('OK', false); - case WsConnectionState.connecting: - return ('...', true); - case WsConnectionState.disconnected: - case null: - return ('OFF', true); - } - } - - @override - Widget build(BuildContext context) { - final theme = AppTheme.of(context); - final (wsText, wsAbnormal) = _wsStatus(); - - return Expanded( - flex: 1, - child: LayoutBuilder( - builder: (context, constraints) { - // Font sizes relative to bar height - final labelSize = constraints.maxHeight * 0.5; - final valueSize = constraints.maxHeight * 0.5; - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - // Left group: WS, GPS, LTE - _Indicator( - label: 'WS', - value: wsText, - isAbnormal: wsAbnormal, - alignment: Alignment.centerLeft, - labelSize: labelSize, - valueSize: valueSize, - flex: 2, - theme: theme, - ), - _Indicator( - label: 'GPS', - value: gpsSatellites?.toString() ?? 'N/A', - isAbnormal: gpsSatellites == null || gpsSatellites == 0, - alignment: Alignment.centerLeft, - labelSize: labelSize, - valueSize: valueSize, - flex: 2, - theme: theme, - ), - _Indicator( - label: 'LTE', - value: lteSignal?.toString() ?? 'N/A', - isAbnormal: lteSignal == null, - alignment: Alignment.centerLeft, - labelSize: labelSize, - valueSize: valueSize, - flex: 2, - theme: theme, - ), - - // Right group: Pi, Chassis - _Indicator( - label: 'Pi', - value: piTemp != null ? '${piTemp!.toStringAsFixed(1)} °C' : 'N/A', - isAbnormal: piTemp == null || piTemp! > 80, - alignment: Alignment.centerLeft, - labelSize: labelSize, - valueSize: valueSize, - flex: 2, - theme: theme, - ), - _Indicator( - label: 'Mains', - value: voltage != null ? '${voltage!.toStringAsFixed(1)} V' : 'N/A', - isAbnormal: voltage == null || voltage! < 11.7 || voltage! > 14.5, - alignment: Alignment.centerLeft, - labelSize: labelSize, - valueSize: valueSize, - flex: 3, - theme: theme, - ), - ], - ), - ); - }, - ), - ); - } -} - -/// Single status indicator in a fixed-width flex slot. -class _Indicator extends StatelessWidget { - final String label; - final String value; - final bool isAbnormal; - final Alignment alignment; - final double labelSize; - final double valueSize; - final int flex; - final AppTheme theme; - - const _Indicator({ - required this.label, - required this.value, - required this.isAbnormal, - required this.alignment, - required this.labelSize, - required this.valueSize, - required this.flex, - required this.theme, - }); - - @override - Widget build(BuildContext context) { - return Expanded( - flex: flex, - child: Align( - alignment: alignment, - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ - Text( - '$label ', - style: TextStyle( - fontSize: labelSize, - color: theme.subdued, - ), - ), - Text( - value, - style: TextStyle( - fontSize: valueSize, - fontFeatures: const [FontFeature.tabularFigures()], - color: isAbnormal ? theme.highlight : theme.foreground, - ), - ), - ], - ), - ), - ); - } -} +import 'package:flutter/material.dart'; + +import '../services/websocket_service.dart'; +import '../theme/app_theme.dart'; + +/// Android-style persistent status bar for system indicators. +/// Shows GPS satellites, LTE signal, Pi temp, voltage, WS status at a glance. +class SystemBar extends StatelessWidget { + final int? gpsSatellites; // null = disconnected + final String? gpsState; // "acquiring", "fix", "lost" + final int? lteSignal; // null = disconnected, 0-4 bars + final double? piTemp; // null = unavailable + final double? voltage; // null = Arduino disconnected + final WsConnectionState? wsState; // WebSocket connection state + + const SystemBar({ + super.key, + this.gpsSatellites, + this.gpsState, + this.lteSignal, + this.piTemp, + this.voltage, + this.wsState, + }); + + /// Get WebSocket status text and abnormal flag + (String, bool) _wsStatus() { + switch (wsState) { + case WsConnectionState.connected: + return ('OK', false); + case WsConnectionState.connecting: + return ('...', true); + case WsConnectionState.disconnected: + case null: + return ('OFF', true); + } + } + + @override + Widget build(BuildContext context) { + final theme = AppTheme.of(context); + final (wsText, wsAbnormal) = _wsStatus(); + + return Expanded( + flex: 1, + child: LayoutBuilder( + builder: (context, constraints) { + // Font sizes relative to bar height + final labelSize = constraints.maxHeight * 0.5; + final valueSize = constraints.maxHeight * 0.5; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Left group: WS, GPS, LTE + _Indicator( + label: 'WS', + value: wsText, + isAbnormal: wsAbnormal, + alignment: Alignment.centerLeft, + labelSize: labelSize, + valueSize: valueSize, + flex: 2, + theme: theme, + ), + _Indicator( + label: 'GPS', + value: gpsState == 'acquiring' ? 'ACQ' + : gpsState == 'fix' ? (gpsSatellites?.toString() ?? 'N/A') + : '0', // lost or unknown + isAbnormal: gpsState != 'fix', + alignment: Alignment.centerLeft, + labelSize: labelSize, + valueSize: valueSize, + flex: 2, + theme: theme, + ), + _Indicator( + label: 'LTE', + value: lteSignal?.toString() ?? 'N/A', + isAbnormal: lteSignal == null, + alignment: Alignment.centerLeft, + labelSize: labelSize, + valueSize: valueSize, + flex: 2, + theme: theme, + ), + + // Right group: Pi, Chassis + _Indicator( + label: 'Pi', + value: piTemp != null ? '${piTemp!.toStringAsFixed(1)} °C' : 'N/A', + isAbnormal: piTemp == null || piTemp! > 80, + alignment: Alignment.centerLeft, + labelSize: labelSize, + valueSize: valueSize, + flex: 2, + theme: theme, + ), + _Indicator( + label: 'Mains', + value: voltage != null ? '${voltage!.toStringAsFixed(1)} V' : 'N/A', + isAbnormal: voltage == null || voltage! < 11.7 || voltage! > 14.5, + alignment: Alignment.centerLeft, + labelSize: labelSize, + valueSize: valueSize, + flex: 3, + theme: theme, + ), + ], + ), + ); + }, + ), + ); + } +} + +/// Single status indicator in a fixed-width flex slot. +class _Indicator extends StatelessWidget { + final String label; + final String value; + final bool isAbnormal; + final Alignment alignment; + final double labelSize; + final double valueSize; + final int flex; + final AppTheme theme; + + const _Indicator({ + required this.label, + required this.value, + required this.isAbnormal, + required this.alignment, + required this.labelSize, + required this.valueSize, + required this.flex, + required this.theme, + }); + + @override + Widget build(BuildContext context) { + return Expanded( + flex: flex, + child: Align( + alignment: alignment, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + '$label ', + style: TextStyle( + fontSize: labelSize, + color: theme.subdued, + ), + ), + Text( + value, + style: TextStyle( + fontSize: valueSize, + fontFeatures: const [FontFeature.tabularFigures()], + color: isAbnormal ? theme.highlight : theme.foreground, + ), + ), + ], + ), + ), + ); + } +} diff --git a/pi/ui/pubspec.yaml b/pi/ui/pubspec.yaml index 9006230..f72930d 100644 --- a/pi/ui/pubspec.yaml +++ b/pi/ui/pubspec.yaml @@ -1,26 +1,26 @@ -name: smartserow_ui -description: Smart Serow embedded UI for Raspberry Pi Zero 2W -publish_to: 'none' -version: 0.1.0 - -environment: - sdk: '>=3.0.0 <4.0.0' - -dependencies: - flutter: - sdk: flutter - http: ^1.2.0 - socket_io_client: ^2.0.3+1 - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^3.0.0 - -flutter: - uses-material-design: true - - fonts: - - family: DIN1451 - fonts: - - asset: assets/fonts/din1451alt.ttf +name: smartserow_ui +description: Smart Serow embedded UI for Raspberry Pi Zero 2W +publish_to: 'none' +version: 0.1.0 + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + flutter: + sdk: flutter + http: ^1.2.0 + socket_io_client: ^2.0.3+1 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^3.0.0 + +flutter: + uses-material-design: true + + fonts: + - family: DIN1451 + fonts: + - asset: assets/fonts/din1451alt.ttf diff --git a/scripts/build-deploy.py b/scripts/build-deploy.py index 14ce632..017e067 100644 --- a/scripts/build-deploy.py +++ b/scripts/build-deploy.py @@ -1,88 +1,88 @@ -#!/usr/bin/env python3 -"""One-click build and deploy for Smart Serow. - -Combines build.py and deploy.py with sensible defaults. -Defaults to --restart since that's usually what you want. -""" - -import argparse -import sys -from pathlib import Path - -# Import sibling modules -sys.path.insert(0, str(Path(__file__).parent)) -from build import build -from deploy import deploy -from deploy_backend import deploy as deploy_backend - - -def main(): - parser = argparse.ArgumentParser( - description="Build and deploy Smart Serow in one step", - ) - parser.add_argument( - "--clean", "-c", - action="store_true", - help="Clean CMake cache before building", - ) - parser.add_argument( - "--no-restart", - action="store_true", - help="Don't restart service after deploy (default: restart)", - ) - parser.add_argument( - "--build-only", - action="store_true", - help="Only build, don't deploy", - ) - parser.add_argument( - "--deploy-only", - action="store_true", - help="Only deploy, don't build", - ) - parser.add_argument( - "--ui", - action="store_true", - help="Build/deploy UI only (no backend)", - ) - parser.add_argument( - "--backend", - action="store_true", - help="Deploy backend only (no UI, no build)", - ) - args = parser.parse_args() - - # Default: both UI and backend if neither flag specified - do_ui = args.ui or not args.backend - do_backend = args.backend or not args.ui - - restart = not args.no_restart - - # Build UI (only if doing UI and not deploy-only) - if do_ui and not args.deploy_only: - print() - if not build(clean=args.clean): - print("UI build failed!") - sys.exit(1) - - # Deploy backend FIRST (no build step needed - it's Python) - # Backend must be up before UI connects to WebSocket - if do_backend and not args.build_only: - print() - if not deploy_backend(restart=restart): - print("Backend deploy failed!") - sys.exit(1) - - # Deploy UI after backend is ready - if do_ui and not args.build_only: - print() - if not deploy(restart=restart): - print("UI deploy failed!") - sys.exit(1) - - print() - print("=== All done! ===") - - -if __name__ == "__main__": - main() +#!/usr/bin/env python3 +"""One-click build and deploy for Smart Serow. + +Combines build.py and deploy.py with sensible defaults. +Defaults to --restart since that's usually what you want. +""" + +import argparse +import sys +from pathlib import Path + +# Import sibling modules +sys.path.insert(0, str(Path(__file__).parent)) +from build import build +from deploy import deploy +from deploy_backend import deploy as deploy_backend + + +def main(): + parser = argparse.ArgumentParser( + description="Build and deploy Smart Serow in one step", + ) + parser.add_argument( + "--clean", "-c", + action="store_true", + help="Clean CMake cache before building", + ) + parser.add_argument( + "--no-restart", + action="store_true", + help="Don't restart service after deploy (default: restart)", + ) + parser.add_argument( + "--build-only", + action="store_true", + help="Only build, don't deploy", + ) + parser.add_argument( + "--deploy-only", + action="store_true", + help="Only deploy, don't build", + ) + parser.add_argument( + "--ui", + action="store_true", + help="Build/deploy UI only (no backend)", + ) + parser.add_argument( + "--backend", + action="store_true", + help="Deploy backend only (no UI, no build)", + ) + args = parser.parse_args() + + # Default: both UI and backend if neither flag specified + do_ui = args.ui or not args.backend + do_backend = args.backend or not args.ui + + restart = not args.no_restart + + # Build UI (only if doing UI and not deploy-only) + if do_ui and not args.deploy_only: + print() + if not build(clean=args.clean): + print("UI build failed!") + sys.exit(1) + + # Deploy backend FIRST (no build step needed - it's Python) + # Backend must be up before UI connects to WebSocket + if do_backend and not args.build_only: + print() + if not deploy_backend(restart=restart): + print("Backend deploy failed!") + sys.exit(1) + + # Deploy UI after backend is ready + if do_ui and not args.build_only: + print() + if not deploy(restart=restart): + print("UI deploy failed!") + sys.exit(1) + + print() + print("=== All done! ===") + + +if __name__ == "__main__": + main() diff --git a/scripts/deploy_backend.py b/scripts/deploy_backend.py index 6cd92ab..8b052e3 100644 --- a/scripts/deploy_backend.py +++ b/scripts/deploy_backend.py @@ -1,157 +1,157 @@ -#!/usr/bin/env python3 -"""Deploy script for Smart Serow Python backend. - -Pushes backend source to Pi and optionally restarts service. -Completely independent from UI deploy. -""" - -import argparse -import json -import subprocess -import sys -import time -from pathlib import Path - - -SCRIPT_DIR = Path(__file__).parent.resolve() -PROJECT_ROOT = SCRIPT_DIR.parent -CONFIG_FILE = SCRIPT_DIR / "deploy_target.json" -BACKEND_DIR = PROJECT_ROOT / "pi" / "backend" -SERVICE_FILE = SCRIPT_DIR / "smartserow-backend.service" - - -def run(cmd: list[str], check: bool = True, **kwargs) -> subprocess.CompletedProcess: - """Run a command.""" - print(f" → {' '.join(cmd)}") - return subprocess.run(cmd, check=check, **kwargs) - - -def load_config() -> dict: - """Load deploy target configuration.""" - if not CONFIG_FILE.exists(): - print(f"ERROR: Config file not found: {CONFIG_FILE}") - print("Create it based on deploy_target.sample.json") - sys.exit(1) - - with open(CONFIG_FILE) as f: - return json.load(f) - - -def deploy(restart: bool = False) -> bool: - """Deploy backend to Pi. Returns True on success.""" - config = load_config() - - pi_user = config["user"] - pi_host = config["host"] - - # Backend-specific config (with defaults) - remote_path = config.get("backend_path", "/opt/smartserow-backend") - service_name = config.get("backend_service", "smartserow-backend") - - ssh_target = f"{pi_user}@{pi_host}" - - print("=== Smart Serow Backend Deploy ===") - print(f"Target: {ssh_target}:{remote_path}") - print(f"Source: {BACKEND_DIR}") - - if not BACKEND_DIR.exists(): - print(f"ERROR: Backend directory not found: {BACKEND_DIR}") - return False - - # Ensure remote directory exists - print() - print("Ensuring remote directory...") - run(["ssh", ssh_target, f"mkdir -p {remote_path}"]) - - # Sync backend source to Pi - # Exclude __pycache__, .venv, etc. - print() - print("Syncing files...") - run([ - "rsync", "-avz", "--delete", - "--exclude", "__pycache__", - "--exclude", "*.pyc", - "--exclude", ".venv", - "--exclude", ".ruff_cache", - "--exclude", "uv.lock", # Let Pi generate its own lockfile - f"{BACKEND_DIR}/", - f"{ssh_target}:{remote_path}/", - ]) - - # Ensure system GPIO package is installed (pip version needs compilation) - print() - print("Ensuring system GPIO package...") - run( - ["ssh", ssh_target, "dpkg -s python3-rpi.gpio >/dev/null 2>&1 || sudo apt install -y python3-rpi.gpio"], - check=False, - ) - - # Create venv with system-site-packages if it doesn't exist - # This allows access to apt-installed packages like python3-rpi.gpio - print() - print("Ensuring venv with system-site-packages...") - run( - ["ssh", ssh_target, f"cd {remote_path} && [ -d .venv ] || ~/.local/bin/uv venv --system-site-packages"], - check=False, - ) - - # Run uv sync to install/update dependencies - # Use full path since non-interactive SSH doesn't load .bashrc - print() - print("Running uv sync...") - result = run( - ["ssh", ssh_target, f"cd {remote_path} && ~/.local/bin/uv sync"], - check=False, - ) - if result.returncode != 0: - print("WARNING: uv sync failed - dependencies may be out of date") - print("Make sure uv is installed on Pi: curl -LsSf https://astral.sh/uv/install.sh | sh") - - # Deploy service file if it exists - if SERVICE_FILE.exists(): - print() - print("Deploying systemd service file...") - run(["scp", str(SERVICE_FILE), f"{ssh_target}:/tmp/"]) - run([ - "ssh", ssh_target, - f"sudo mv /tmp/{SERVICE_FILE.name} /etc/systemd/system/ && sudo systemctl daemon-reload" - ]) - - # Restart service if requested - if restart: - print() - print(f"Restarting service: {service_name}") - run(["ssh", ssh_target, f"sudo systemctl restart {service_name}"], check=False) - time.sleep(2) - run(["ssh", ssh_target, f"systemctl status {service_name} --no-pager"], check=False) - else: - print() - print("Deploy complete. To restart service, run:") - print(f" ssh {ssh_target} 'sudo systemctl restart {service_name}'") - print() - print("Or run this script with --restart flag") - - print() - print("Note: First-time setup on Pi requires:") - print(f" ssh {ssh_target}") - print(" curl -LsSf https://astral.sh/uv/install.sh | sh # Install uv") - print(" sudo apt install python3-rpi.gpio # GPIO support") - - return True - - -def main(): - parser = argparse.ArgumentParser(description="Deploy Smart Serow backend to Pi") - parser.add_argument( - "--restart", "-r", - action="store_true", - help="Restart the systemd service after deploy", - ) - args = parser.parse_args() - - success = deploy(restart=args.restart) - sys.exit(0 if success else 1) - - -if __name__ == "__main__": - main() +#!/usr/bin/env python3 +"""Deploy script for Smart Serow Python backend. + +Pushes backend source to Pi and optionally restarts service. +Completely independent from UI deploy. +""" + +import argparse +import json +import subprocess +import sys +import time +from pathlib import Path + + +SCRIPT_DIR = Path(__file__).parent.resolve() +PROJECT_ROOT = SCRIPT_DIR.parent +CONFIG_FILE = SCRIPT_DIR / "deploy_target.json" +BACKEND_DIR = PROJECT_ROOT / "pi" / "backend" +SERVICE_FILE = SCRIPT_DIR / "smartserow-backend.service" + + +def run(cmd: list[str], check: bool = True, **kwargs) -> subprocess.CompletedProcess: + """Run a command.""" + print(f" → {' '.join(cmd)}") + return subprocess.run(cmd, check=check, **kwargs) + + +def load_config() -> dict: + """Load deploy target configuration.""" + if not CONFIG_FILE.exists(): + print(f"ERROR: Config file not found: {CONFIG_FILE}") + print("Create it based on deploy_target.sample.json") + sys.exit(1) + + with open(CONFIG_FILE) as f: + return json.load(f) + + +def deploy(restart: bool = False) -> bool: + """Deploy backend to Pi. Returns True on success.""" + config = load_config() + + pi_user = config["user"] + pi_host = config["host"] + + # Backend-specific config (with defaults) + remote_path = config.get("backend_path", "/opt/smartserow-backend") + service_name = config.get("backend_service", "smartserow-backend") + + ssh_target = f"{pi_user}@{pi_host}" + + print("=== Smart Serow Backend Deploy ===") + print(f"Target: {ssh_target}:{remote_path}") + print(f"Source: {BACKEND_DIR}") + + if not BACKEND_DIR.exists(): + print(f"ERROR: Backend directory not found: {BACKEND_DIR}") + return False + + # Ensure remote directory exists + print() + print("Ensuring remote directory...") + run(["ssh", ssh_target, f"mkdir -p {remote_path}"]) + + # Sync backend source to Pi + # Exclude __pycache__, .venv, etc. + print() + print("Syncing files...") + run([ + "rsync", "-avz", "--delete", + "--exclude", "__pycache__", + "--exclude", "*.pyc", + "--exclude", ".venv", + "--exclude", ".ruff_cache", + "--exclude", "uv.lock", # Let Pi generate its own lockfile + f"{BACKEND_DIR}/", + f"{ssh_target}:{remote_path}/", + ]) + + # Ensure system GPIO package is installed (pip version needs compilation) + print() + print("Ensuring system GPIO package...") + run( + ["ssh", ssh_target, "dpkg -s python3-rpi.gpio >/dev/null 2>&1 || sudo apt install -y python3-rpi.gpio"], + check=False, + ) + + # Create venv with system-site-packages if it doesn't exist + # This allows access to apt-installed packages like python3-rpi.gpio + print() + print("Ensuring venv with system-site-packages...") + run( + ["ssh", ssh_target, f"cd {remote_path} && [ -d .venv ] || ~/.local/bin/uv venv --system-site-packages"], + check=False, + ) + + # Run uv sync to install/update dependencies + # Use full path since non-interactive SSH doesn't load .bashrc + print() + print("Running uv sync...") + result = run( + ["ssh", ssh_target, f"cd {remote_path} && ~/.local/bin/uv sync"], + check=False, + ) + if result.returncode != 0: + print("WARNING: uv sync failed - dependencies may be out of date") + print("Make sure uv is installed on Pi: curl -LsSf https://astral.sh/uv/install.sh | sh") + + # Deploy service file if it exists + if SERVICE_FILE.exists(): + print() + print("Deploying systemd service file...") + run(["scp", str(SERVICE_FILE), f"{ssh_target}:/tmp/"]) + run([ + "ssh", ssh_target, + f"sudo mv /tmp/{SERVICE_FILE.name} /etc/systemd/system/ && sudo systemctl daemon-reload" + ]) + + # Restart service if requested + if restart: + print() + print(f"Restarting service: {service_name}") + run(["ssh", ssh_target, f"sudo systemctl restart {service_name}"], check=False) + time.sleep(2) + run(["ssh", ssh_target, f"systemctl status {service_name} --no-pager"], check=False) + else: + print() + print("Deploy complete. To restart service, run:") + print(f" ssh {ssh_target} 'sudo systemctl restart {service_name}'") + print() + print("Or run this script with --restart flag") + + print() + print("Note: First-time setup on Pi requires:") + print(f" ssh {ssh_target}") + print(" curl -LsSf https://astral.sh/uv/install.sh | sh # Install uv") + print(" sudo apt install python3-rpi.gpio # GPIO support") + + return True + + +def main(): + parser = argparse.ArgumentParser(description="Deploy Smart Serow backend to Pi") + parser.add_argument( + "--restart", "-r", + action="store_true", + help="Restart the systemd service after deploy", + ) + args = parser.parse_args() + + success = deploy(restart=args.restart) + sys.exit(0 if success else 1) + + +if __name__ == "__main__": + main()