diff --git a/README.md b/README.md index 90c7621..9926076 100644 --- a/README.md +++ b/README.md @@ -26,13 +26,14 @@ smart-serow/ │ ├── pubspec.yaml │ └── elinux/ # Generated by flutter-elinux (gitignored) ├── scripts/ -│ ├── build.sh # Cross-compile for ARM64 -│ ├── deploy.sh # Push to Pi via rsync -│ ├── deploy_target.json +│ ├── build.py # Cross-compile for ARM64 +│ ├── deploy.py # Push to Pi via rsync +│ ├── build-deploy.py # One-click build + deploy +│ ├── deploy_target.sample.json │ ├── pi_setup.sh # One-time Pi setup -│ └── smartserow-ui.service +│ └── smartserow-ui.service.sample ├── pi_sysroot/ # Pi libraries for cross-linking (gitignored) -└── LICENSE # MIT +└── LICENSE ``` --- @@ -100,33 +101,40 @@ This installs: ## Build & Deploy -### Build (in WSL2) +### One-liner (recommended) ```bash -./scripts/build.sh # Normal build -./scripts/build.sh --clean # Clean CMake cache first +python3 scripts/build-deploy.py # Build, deploy, restart +python3 scripts/build-deploy.py --clean # Clean build first +python3 scripts/build-deploy.py --no-restart # Don't restart service +``` + +### Individual scripts + +```bash +# Build only +python3 scripts/build.py +python3 scripts/build.py --clean + +# Deploy only +python3 scripts/deploy.py +python3 scripts/deploy.py --restart ``` Build output: `pi/ui/build/elinux/arm64/release/bundle/` -### Deploy +### Deploy config -Edit `scripts/deploy_target.json`: +Copy and edit `scripts/deploy_target.sample.json` → `scripts/deploy_target.json`: ```json { - "user": "mikkeli", - "host": "smartserow.local", + "user": "pi", + "host": "raspberrypi.local", "remote_path": "/opt/smartserow", "service_name": "smartserow-ui" } ``` -Deploy: -```bash -./scripts/deploy.sh # Just copy files -./scripts/deploy.sh --restart # Copy and restart service -``` - ### Verify ```bash diff --git a/pi/ui/lib/main.dart b/pi/ui/lib/main.dart index 121d6cb..98504d2 100644 --- a/pi/ui/lib/main.dart +++ b/pi/ui/lib/main.dart @@ -1,3 +1,5 @@ +import 'dart:async'; +import 'dart:math'; import 'package:flutter/material.dart'; void main() { @@ -19,13 +21,64 @@ class SmartSerowApp extends StatelessWidget { ), useMaterial3: true, ), - home: const HomePage(), + home: const AppRoot(), ); } } -class HomePage extends StatelessWidget { - const HomePage({super.key}); +/// Root widget that manages app state transitions +class AppRoot extends StatefulWidget { + const AppRoot({super.key}); + + @override + State createState() => _AppRootState(); +} + +class _AppRootState extends State { + bool _initialized = false; + String _initStatus = 'Starting...'; + + @override + void initState() { + super.initState(); + _runInitSequence(); + } + + Future _runInitSequence() async { + // Simulate init checks - replace with real checks later + // (UART, GPS, sensors, etc.) + + setState(() => _initStatus = 'Checking systems...'); + await Future.delayed(const Duration(milliseconds: 800)); + + setState(() => _initStatus = 'UART: standby'); + await Future.delayed(const Duration(milliseconds: 400)); + + setState(() => _initStatus = 'GPS: standby'); + await Future.delayed(const Duration(milliseconds: 400)); + + setState(() => _initStatus = 'Ready'); + await Future.delayed(const Duration(milliseconds: 300)); + + setState(() => _initialized = true); + } + + @override + Widget build(BuildContext context) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 500), + child: _initialized + ? const DashboardScreen(key: ValueKey('dashboard')) + : SplashScreen(key: const ValueKey('splash'), status: _initStatus), + ); + } +} + +/// Splash screen - shown during initialization +class SplashScreen extends StatelessWidget { + final String status; + + const SplashScreen({super.key, required this.status}); @override Widget build(BuildContext context) { @@ -48,9 +101,9 @@ class HomePage extends StatelessWidget { fontWeight: FontWeight.bold, ), ), - const SizedBox(height: 8), + const SizedBox(height: 16), Text( - 'System Ready', + status, style: Theme.of(context).textTheme.bodyLarge?.copyWith( color: Colors.grey, ), @@ -61,3 +114,141 @@ class HomePage extends StatelessWidget { ); } } + +/// Main dashboard - placeholder with random updating values +class DashboardScreen extends StatefulWidget { + const DashboardScreen({super.key}); + + @override + State createState() => _DashboardScreenState(); +} + +class _DashboardScreenState extends State { + final _random = Random(); + Timer? _timer; + + int _speed = 0; + int _rpm = 0; + double _voltage = 12.6; + int _temp = 25; + + @override + void initState() { + super.initState(); + // Update random values every 500ms - simulates live data + _timer = Timer.periodic(const Duration(milliseconds: 500), (_) { + setState(() { + _speed = _random.nextInt(120); + _rpm = 1000 + _random.nextInt(8000); + _voltage = 11.5 + _random.nextDouble() * 2; + _temp = 20 + _random.nextInt(60); + }); + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: Padding( + padding: const EdgeInsets.all(32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'SMART SEROW', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Colors.teal, + letterSpacing: 2, + ), + ), + Text( + '${_voltage.toStringAsFixed(1)}V', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: _voltage < 12.0 ? Colors.red : Colors.green, + ), + ), + ], + ), + + const SizedBox(height: 48), + + // Main speed display + Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '$_speed', + style: const TextStyle( + fontSize: 180, + fontWeight: FontWeight.w200, + color: Colors.white, + height: 1, + ), + ), + Text( + 'km/h', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: Colors.grey, + ), + ), + ], + ), + ), + ), + + // Bottom stats row + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _StatBox(label: 'RPM', value: _rpm.toString()), + _StatBox(label: 'TEMP', value: '$_temp°C'), + _StatBox(label: 'GEAR', value: '—'), + ], + ), + ], + ), + ), + ); + } +} + +class _StatBox extends StatelessWidget { + final String label; + final String value; + + const _StatBox({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Text( + value, + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + color: Colors.white, + ), + ), + Text( + label, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey, + letterSpacing: 1, + ), + ), + ], + ); + } +} diff --git a/scripts/build-deploy.py b/scripts/build-deploy.py new file mode 100644 index 0000000..943633d --- /dev/null +++ b/scripts/build-deploy.py @@ -0,0 +1,64 @@ +#!/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 + + +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", + ) + args = parser.parse_args() + + # Build + if not args.deploy_only: + print() + if not build(clean=args.clean): + print("Build failed!") + sys.exit(1) + + # Deploy + if not args.build_only: + print() + restart = not args.no_restart + if not deploy(restart=restart): + print("Deploy failed!") + sys.exit(1) + + print() + print("=== All done! ===") + + +if __name__ == "__main__": + main() diff --git a/scripts/build-deploy.sh b/scripts/build-deploy.sh new file mode 100644 index 0000000..311db76 --- /dev/null +++ b/scripts/build-deploy.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# Wrapper for build-deploy.py +# Usage: ./build-deploy.sh [options] + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec python3 "$SCRIPT_DIR/build-deploy.py" "$@" diff --git a/scripts/build.py b/scripts/build.py new file mode 100644 index 0000000..dcacfe6 --- /dev/null +++ b/scripts/build.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Build script for Smart Serow Flutter UI. + +Run this in WSL2 with flutter-elinux installed. +""" + +import argparse +import os +import shutil +import subprocess +import sys +from pathlib import Path + + +SCRIPT_DIR = Path(__file__).parent.resolve() +PROJECT_ROOT = SCRIPT_DIR.parent +UI_DIR = PROJECT_ROOT / "pi" / "ui" +BUILD_OUTPUT = UI_DIR / "build" / "elinux" / "arm64" / "release" / "bundle" + + +def run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess: + """Run a command, exit on failure.""" + print(f" → {' '.join(cmd)}") + result = subprocess.run(cmd, **kwargs) + if result.returncode != 0: + sys.exit(result.returncode) + return result + + +def check_flutter_elinux() -> str: + """Check if flutter-elinux is available, return path.""" + result = subprocess.run( + ["which", "flutter-elinux"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + print("ERROR: flutter-elinux not found in PATH") + print("Install it or check your PATH") + print(f"\nCurrent PATH: {os.environ.get('PATH', '')}") + sys.exit(1) + return result.stdout.strip() + + +def set_cross_compile_env(): + """Set environment variables for ARM64 cross-compilation.""" + env_vars = { + "CC": "aarch64-linux-gnu-gcc", + "CXX": "aarch64-linux-gnu-g++", + "AR": "aarch64-linux-gnu-ar", + "LD": "aarch64-linux-gnu-ld", + "CMAKE_C_COMPILER": "aarch64-linux-gnu-gcc", + "CMAKE_CXX_COMPILER": "aarch64-linux-gnu-g++", + } + os.environ.update(env_vars) + return env_vars + + +def build(clean: bool = False) -> bool: + """Run the build process. Returns True on success.""" + print("=== Smart Serow Build ===") + print(f"Project: {UI_DIR}") + + # Check flutter-elinux + flutter_path = check_flutter_elinux() + print(f"Using: {flutter_path}") + + # Set cross-compilation env + env_vars = set_cross_compile_env() + print(f"Cross-compiler: {env_vars['CXX']}") + + os.chdir(UI_DIR) + + # Initialize elinux project if needed + elinux_dir = UI_DIR / "elinux" + if not elinux_dir.exists(): + print("Initializing elinux project structure...") + run([ + "flutter-elinux", "create", ".", + "--project-name", "smartserow_ui", + "--org", "com.smartserow", + ]) + + # Clean if requested + if clean: + cache_dir = UI_DIR / "build" / "elinux" / "arm64" + if cache_dir.exists(): + print("Cleaning CMake cache...") + shutil.rmtree(cache_dir) + + # Fetch dependencies + print("Fetching dependencies...") + run(["flutter-elinux", "pub", "get"]) + + # Build command + print("Building for ARM64 (elinux) with DRM-GBM backend...") + + build_cmd = [ + "flutter-elinux", "build", "elinux", + "--target-arch=arm64", + "--target-backend-type=gbm", + "--target-compiler-triple=aarch64-linux-gnu", + "--release", + ] + + # Add sysroot if available + sysroot = PROJECT_ROOT / "pi_sysroot" + if sysroot.exists(): + print(f"Using Pi sysroot: {sysroot}") + build_cmd.append(f"--target-sysroot={sysroot}") + + run(build_cmd) + + # Verify output + if BUILD_OUTPUT.exists(): + print() + print("=== Build Complete ===") + print(f"Output: {BUILD_OUTPUT}") + for f in BUILD_OUTPUT.iterdir(): + size = f.stat().st_size + print(f" {f.name}: {size:,} bytes") + return True + else: + print(f"ERROR: Build output not found at {BUILD_OUTPUT}") + return False + + +def main(): + parser = argparse.ArgumentParser(description="Build Smart Serow Flutter UI") + parser.add_argument( + "--clean", "-c", + action="store_true", + help="Clean CMake cache before building", + ) + args = parser.parse_args() + + success = build(clean=args.clean) + sys.exit(0 if success else 1) + + +if __name__ == "__main__": + main() diff --git a/scripts/deploy.py b/scripts/deploy.py new file mode 100644 index 0000000..b537589 --- /dev/null +++ b/scripts/deploy.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Deploy script for Smart Serow Flutter UI. + +Pushes build bundle to Pi and optionally restarts service. +""" + +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" +BUILD_DIR = PROJECT_ROOT / "pi" / "ui" / "build" / "elinux" / "arm64" / "release" / "bundle" + + +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 to Pi. Returns True on success.""" + config = load_config() + + pi_user = config["user"] + pi_host = config["host"] + remote_path = config["remote_path"] + service_name = config["service_name"] + + ssh_target = f"{pi_user}@{pi_host}" + + print("=== Smart Serow Deploy ===") + print(f"Target: {ssh_target}:{remote_path}") + print(f"Source: {BUILD_DIR}") + + if not BUILD_DIR.exists(): + print("ERROR: Build directory not found. Run build.py first.") + return False + + # Sync build to Pi + print() + print("Syncing files...") + run([ + "rsync", "-avz", "--delete", + f"{BUILD_DIR}/", + f"{ssh_target}:{remote_path}/bundle/", + ]) + + # Restart service if requested + if restart: + print() + print(f"Restarting service: {service_name}") + run(["ssh", ssh_target, f"sudo systemctl restart {service_name}"]) + 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") + + return True + + +def main(): + parser = argparse.ArgumentParser(description="Deploy Smart Serow 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() diff --git a/scripts/deploy_target.sample.json b/scripts/deploy_target.sample.json index c779196..64a7dcc 100644 --- a/scripts/deploy_target.sample.json +++ b/scripts/deploy_target.sample.json @@ -1,6 +1,6 @@ { "user": "pi", - "host": "192.168.114.5", + "host": "raspberrypi.local", "remote_path": "/opt/smartserow", "service_name": "smartserow-ui" } diff --git a/scripts/smartserow-ui sample.service b/scripts/smartserow-ui.service.sample similarity index 100% rename from scripts/smartserow-ui sample.service rename to scripts/smartserow-ui.service.sample