new python build-deploy chain

This commit is contained in:
Mikkeli Matlock
2026-01-25 00:34:01 +09:00
parent 42e2094635
commit 754cecffd4
8 changed files with 532 additions and 24 deletions

View File

@@ -26,13 +26,14 @@ smart-serow/
│ ├── pubspec.yaml │ ├── pubspec.yaml
│ └── elinux/ # Generated by flutter-elinux (gitignored) │ └── elinux/ # Generated by flutter-elinux (gitignored)
├── scripts/ ├── scripts/
│ ├── build.sh # Cross-compile for ARM64 │ ├── build.py # Cross-compile for ARM64
│ ├── deploy.sh # Push to Pi via rsync │ ├── deploy.py # Push to Pi via rsync
│ ├── deploy_target.json │ ├── build-deploy.py # One-click build + deploy
│ ├── deploy_target.sample.json
│ ├── pi_setup.sh # One-time Pi setup │ ├── pi_setup.sh # One-time Pi setup
│ └── smartserow-ui.service │ └── smartserow-ui.service.sample
├── pi_sysroot/ # Pi libraries for cross-linking (gitignored) ├── pi_sysroot/ # Pi libraries for cross-linking (gitignored)
└── LICENSE # MIT └── LICENSE
``` ```
--- ---
@@ -100,33 +101,40 @@ This installs:
## Build & Deploy ## Build & Deploy
### Build (in WSL2) ### One-liner (recommended)
```bash ```bash
./scripts/build.sh # Normal build python3 scripts/build-deploy.py # Build, deploy, restart
./scripts/build.sh --clean # Clean CMake cache first 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/` 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 ```json
{ {
"user": "mikkeli", "user": "pi",
"host": "smartserow.local", "host": "raspberrypi.local",
"remote_path": "/opt/smartserow", "remote_path": "/opt/smartserow",
"service_name": "smartserow-ui" "service_name": "smartserow-ui"
} }
``` ```
Deploy:
```bash
./scripts/deploy.sh # Just copy files
./scripts/deploy.sh --restart # Copy and restart service
```
### Verify ### Verify
```bash ```bash

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
void main() { void main() {
@@ -19,13 +21,64 @@ class SmartSerowApp extends StatelessWidget {
), ),
useMaterial3: true, useMaterial3: true,
), ),
home: const HomePage(), home: const AppRoot(),
); );
} }
} }
class HomePage extends StatelessWidget { /// Root widget that manages app state transitions
const HomePage({super.key}); class AppRoot extends StatefulWidget {
const AppRoot({super.key});
@override
State<AppRoot> createState() => _AppRootState();
}
class _AppRootState extends State<AppRoot> {
bool _initialized = false;
String _initStatus = 'Starting...';
@override
void initState() {
super.initState();
_runInitSequence();
}
Future<void> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -48,9 +101,9 @@ class HomePage extends StatelessWidget {
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 16),
Text( Text(
'System Ready', status,
style: Theme.of(context).textTheme.bodyLarge?.copyWith( style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Colors.grey, 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<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends State<DashboardScreen> {
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,
),
),
],
);
}
}

64
scripts/build-deploy.py Normal file
View File

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

6
scripts/build-deploy.sh Normal file
View File

@@ -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" "$@"

142
scripts/build.py Normal file
View File

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

97
scripts/deploy.py Normal file
View File

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

View File

@@ -1,6 +1,6 @@
{ {
"user": "pi", "user": "pi",
"host": "192.168.114.5", "host": "raspberrypi.local",
"remote_path": "/opt/smartserow", "remote_path": "/opt/smartserow",
"service_name": "smartserow-ui" "service_name": "smartserow-ui"
} }