diff --git a/pi/ui/config.json b/pi/ui/config.json new file mode 100644 index 0000000..df5c21f --- /dev/null +++ b/pi/ui/config.json @@ -0,0 +1,7 @@ +{ + "overheat": { + "threshold_celsius": 75.0, + "trigger_duration_sec": 10, + "shutdown_delay_sec": 10 + } +} diff --git a/pi/ui/lib/app_root.dart b/pi/ui/lib/app_root.dart index 4015afd..395d163 100644 --- a/pi/ui/lib/app_root.dart +++ b/pi/ui/lib/app_root.dart @@ -2,6 +2,9 @@ import 'package:flutter/material.dart'; import 'screens/splash_screen.dart'; import 'screens/dashboard_screen.dart'; +import 'screens/overheat_screen.dart'; +import 'services/config_service.dart'; +import 'services/overheat_monitor.dart'; /// Root widget that manages app state transitions class AppRoot extends StatefulWidget { @@ -13,6 +16,7 @@ class AppRoot extends StatefulWidget { class _AppRootState extends State { bool _initialized = false; + bool _overheatTriggered = false; String _initStatus = 'Starting...'; @override @@ -21,7 +25,17 @@ class _AppRootState extends State { _runInitSequence(); } + @override + void dispose() { + OverheatMonitor.instance.stop(); + super.dispose(); + } + Future _runInitSequence() async { + // Load config first + setState(() => _initStatus = 'Loading config...'); + await ConfigService.instance.load(); + // Simulate init checks - replace with real checks later // (UART, GPS, sensors, etc.) @@ -37,16 +51,31 @@ class _AppRootState extends State { setState(() => _initStatus = 'Ready'); await Future.delayed(const Duration(milliseconds: 300)); + // Start overheat monitoring + OverheatMonitor.instance.start( + onOverheat: () { + setState(() => _overheatTriggered = true); + }, + ); + setState(() => _initialized = true); } @override Widget build(BuildContext context) { + // Determine which screen to show (priority: overheat > splash > dashboard) + Widget child; + if (_overheatTriggered) { + child = const OverheatScreen(key: ValueKey('overheat')); + } else if (!_initialized) { + child = SplashScreen(key: const ValueKey('splash'), status: _initStatus); + } else { + child = const DashboardScreen(key: ValueKey('dashboard')); + } + return AnimatedSwitcher( duration: const Duration(milliseconds: 500), - child: _initialized - ? const DashboardScreen(key: ValueKey('dashboard')) - : SplashScreen(key: const ValueKey('splash'), status: _initStatus), + child: child, ); } } diff --git a/pi/ui/lib/screens/overheat_screen.dart b/pi/ui/lib/screens/overheat_screen.dart new file mode 100644 index 0000000..443657e --- /dev/null +++ b/pi/ui/lib/screens/overheat_screen.dart @@ -0,0 +1,152 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import '../services/config_service.dart'; +import '../services/pi_io.dart'; + +/// Overheat warning screen with shutdown countdown +/// +/// Shows current temp, threshold, and countdown to poweroff. +/// When countdown hits zero, executes system shutdown. +class OverheatScreen extends StatefulWidget { + const OverheatScreen({super.key}); + + @override + State createState() => _OverheatScreenState(); +} + +class _OverheatScreenState extends State { + Timer? _countdownTimer; + Timer? _tempRefreshTimer; + late int _secondsRemaining; + double? _currentTemp; + + @override + void initState() { + super.initState(); + + _secondsRemaining = ConfigService.instance.shutdownDelay.inSeconds; + _currentTemp = PiIO.instance.getTemperature(); + + // Countdown timer - ticks every second + _countdownTimer = Timer.periodic(const Duration(seconds: 1), (_) { + setState(() { + _secondsRemaining--; + }); + + if (_secondsRemaining <= 0) { + _countdownTimer?.cancel(); + _executeShutdown(); + } + }); + + // Keep temp display updated + _tempRefreshTimer = Timer.periodic(const Duration(milliseconds: 500), (_) { + setState(() { + _currentTemp = PiIO.instance.getTemperature(); + }); + }); + } + + @override + void dispose() { + _countdownTimer?.cancel(); + _tempRefreshTimer?.cancel(); + super.dispose(); + } + + Future _executeShutdown() async { + // Try shutdown commands in order of preference + // Requires passwordless sudo for 'shutdown' command (see sudoers note below) + final commands = [ + ['sudo', 'shutdown', '-h', 'now'], + ['sudo', 'poweroff'], + ['systemctl', 'poweroff'], // Might work with polkit + ]; + + for (final cmd in commands) { + try { + final result = await Process.run(cmd.first, cmd.skip(1).toList()); + if (result.exitCode == 0) return; // Success + } catch (e) { + // Command not found or other error, try next + } + } + // All failed - we're probably not on Linux or no permissions + // Pi should have passwordless sudo configured: + // echo "pi ALL=(ALL) NOPASSWD: /sbin/shutdown" | sudo tee /etc/sudoers.d/shutdown + } + + @override + Widget build(BuildContext context) { + final threshold = ConfigService.instance.overheatThreshold; + + return Scaffold( + backgroundColor: Colors.black, + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Warning icon + const Icon( + Icons.warning_amber_rounded, + size: 100, + color: Colors.red, + ), + + const SizedBox(height: 16), + + // OVERHEATING text + const Text( + 'OVERHEATING', + style: TextStyle( + fontSize: 48, + fontWeight: FontWeight.bold, + color: Colors.red, + letterSpacing: 4, + ), + ), + + const SizedBox(height: 48), + + // Current temperature + Text( + _currentTemp != null ? '${_currentTemp!.toStringAsFixed(1)}°C' : '—', + style: const TextStyle( + fontSize: 120, + fontWeight: FontWeight.w200, + color: Colors.white, + height: 1, + ), + ), + + const SizedBox(height: 8), + + // Threshold info + Text( + 'Threshold: ${threshold.toStringAsFixed(0)}°C', + style: const TextStyle( + fontSize: 24, + color: Colors.grey, + ), + ), + + const SizedBox(height: 48), + + // Countdown + Text( + 'Shutdown in $_secondsRemaining s', + style: const TextStyle( + fontSize: 32, + color: Colors.orange, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + } +} diff --git a/pi/ui/lib/services/config_service.dart b/pi/ui/lib/services/config_service.dart new file mode 100644 index 0000000..9f54fa1 --- /dev/null +++ b/pi/ui/lib/services/config_service.dart @@ -0,0 +1,69 @@ +import 'dart:convert'; +import 'dart:io'; + +/// Configuration service - loads and caches values from config.json +/// +/// Uses singleton pattern. Falls back to defaults if config file missing. +class ConfigService { + ConfigService._(); + static final instance = ConfigService._(); + + // Loaded config cache + Map? _config; + bool _loaded = false; + + // Defaults + static const double _defaultThreshold = 80.0; + static const int _defaultTriggerDuration = 10; + static const int _defaultShutdownDelay = 10; + + /// Load config from JSON file + /// + /// Looks for config.json in same directory as executable. + /// Safe to call multiple times - only loads once. + Future load() async { + if (_loaded) return; + + try { + // Config file sits next to the executable + final exePath = Platform.resolvedExecutable; + final exeDir = File(exePath).parent.path; + final configPath = '$exeDir${Platform.pathSeparator}config.json'; + + final file = File(configPath); + if (await file.exists()) { + final content = await file.readAsString(); + _config = jsonDecode(content) as Map; + } + } catch (e) { + // Config parse error - fall back to defaults + _config = null; + } + + _loaded = true; + } + + /// CPU temperature threshold in Celsius + double get overheatThreshold { + final overheat = _config?['overheat'] as Map?; + final value = overheat?['threshold_celsius']; + if (value is num) return value.toDouble(); + return _defaultThreshold; + } + + /// How long temp must exceed threshold before triggering + Duration get overheatTriggerDuration { + final overheat = _config?['overheat'] as Map?; + final value = overheat?['trigger_duration_sec']; + if (value is int) return Duration(seconds: value); + return Duration(seconds: _defaultTriggerDuration); + } + + /// Countdown before shutdown after overheat triggers + Duration get shutdownDelay { + final overheat = _config?['overheat'] as Map?; + final value = overheat?['shutdown_delay_sec']; + if (value is int) return Duration(seconds: value); + return Duration(seconds: _defaultShutdownDelay); + } +} diff --git a/pi/ui/lib/services/overheat_monitor.dart b/pi/ui/lib/services/overheat_monitor.dart new file mode 100644 index 0000000..70a089b --- /dev/null +++ b/pi/ui/lib/services/overheat_monitor.dart @@ -0,0 +1,77 @@ +import 'dart:async'; + +import 'config_service.dart'; +import 'pi_io.dart'; + +/// Monitors CPU temperature and triggers overheat condition +/// +/// Singleton pattern. Polls temp at 500ms intervals to match dashboard. +/// When temp exceeds threshold for trigger duration, fires callback. +class OverheatMonitor { + OverheatMonitor._(); + static final instance = OverheatMonitor._(); + + Timer? _timer; + int _consecutiveOverheatSamples = 0; + bool _triggered = false; + + static const Duration _pollInterval = Duration(milliseconds: 500); + + /// Current temperature (from PiIO cache) + double? get currentTemp => PiIO.instance.getTemperature(); + + /// Whether overheat condition has triggered + bool get isTriggered => _triggered; + + /// How long we've been over threshold + Duration get timeOverThreshold => + Duration(milliseconds: _consecutiveOverheatSamples * _pollInterval.inMilliseconds); + + /// Start monitoring with callback when overheat triggers + /// + /// [onOverheat] fires once when temp exceeds threshold for configured duration. + /// Safe to call multiple times - restarts monitoring. + void start({required VoidCallback onOverheat}) { + stop(); + _triggered = false; + _consecutiveOverheatSamples = 0; + + final threshold = ConfigService.instance.overheatThreshold; + final triggerDuration = ConfigService.instance.overheatTriggerDuration; + + _timer = Timer.periodic(_pollInterval, (_) { + if (_triggered) return; // Already fired + + final temp = PiIO.instance.getTemperature(); + if (temp == null) return; // No reading yet + + if (temp > threshold) { + _consecutiveOverheatSamples++; + + final overThresholdTime = timeOverThreshold; + if (overThresholdTime >= triggerDuration) { + _triggered = true; + onOverheat(); + } + } else { + // Temp dropped below threshold - reset counter + _consecutiveOverheatSamples = 0; + } + }); + } + + /// Stop monitoring + void stop() { + _timer?.cancel(); + _timer = null; + } + + /// Reset state (for testing/recovery) + void reset() { + _triggered = false; + _consecutiveOverheatSamples = 0; + } +} + +/// Callback signature for void functions +typedef VoidCallback = void Function(); diff --git a/scripts/__pycache__/build.cpython-39.pyc b/scripts/__pycache__/build.cpython-39.pyc deleted file mode 100644 index 94c3e1a..0000000 Binary files a/scripts/__pycache__/build.cpython-39.pyc and /dev/null differ diff --git a/scripts/__pycache__/deploy.cpython-39.pyc b/scripts/__pycache__/deploy.cpython-39.pyc deleted file mode 100644 index 8b82490..0000000 Binary files a/scripts/__pycache__/deploy.cpython-39.pyc and /dev/null differ diff --git a/scripts/deploy.py b/scripts/deploy.py index b537589..efc2a3b 100644 --- a/scripts/deploy.py +++ b/scripts/deploy.py @@ -16,6 +16,7 @@ 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" +CONFIG_SRC = PROJECT_ROOT / "pi" / "ui" / "config.json" def run(cmd: list[str], check: bool = True, **kwargs) -> subprocess.CompletedProcess: @@ -63,6 +64,19 @@ def deploy(restart: bool = False) -> bool: f"{ssh_target}:{remote_path}/bundle/", ]) + # Sync config.json (sits next to executable in bundle) + if CONFIG_SRC.exists(): + print() + print("Syncing config.json...") + run([ + "rsync", "-avz", + str(CONFIG_SRC), + f"{ssh_target}:{remote_path}/bundle/config.json", + ]) + else: + print() + print("Note: No config.json found, using defaults") + # Restart service if requested if restart: print() diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 0df1a93..5d3d92f 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -27,6 +27,7 @@ SERVICE_NAME=$(read_json service_name) SSH_TARGET="$PI_USER@$PI_HOST" BUILD_DIR="$PROJECT_ROOT/pi/ui/build/elinux/arm64/release/bundle" +CONFIG_SRC="$PROJECT_ROOT/pi/ui/config.json" echo "=== Smart Serow Deploy ===" echo "Target: $SSH_TARGET:$REMOTE_PATH" @@ -44,6 +45,16 @@ rsync -avz --delete \ "$BUILD_DIR/" \ "$SSH_TARGET:$REMOTE_PATH/bundle/" +# Sync config.json (sits next to executable in bundle) +if [ -f "$CONFIG_SRC" ]; then + echo "" + echo "Syncing config.json..." + rsync -avz "$CONFIG_SRC" "$SSH_TARGET:$REMOTE_PATH/bundle/config.json" +else + echo "" + echo "Note: No config.json found, using defaults" +fi + # Restart service if requested RESTART="${1:-}" if [ "$RESTART" = "--restart" ] || [ "$RESTART" = "-r" ]; then