overheat monitor with auto poweroff
- needs passwordless sudo on target machine or deploy to run as root
This commit is contained in:
7
pi/ui/config.json
Normal file
7
pi/ui/config.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"overheat": {
|
||||
"threshold_celsius": 75.0,
|
||||
"trigger_duration_sec": 10,
|
||||
"shutdown_delay_sec": 10
|
||||
}
|
||||
}
|
||||
@@ -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<AppRoot> {
|
||||
bool _initialized = false;
|
||||
bool _overheatTriggered = false;
|
||||
String _initStatus = 'Starting...';
|
||||
|
||||
@override
|
||||
@@ -21,7 +25,17 @@ class _AppRootState extends State<AppRoot> {
|
||||
_runInitSequence();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
OverheatMonitor.instance.stop();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _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<AppRoot> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
152
pi/ui/lib/screens/overheat_screen.dart
Normal file
152
pi/ui/lib/screens/overheat_screen.dart
Normal file
@@ -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<OverheatScreen> createState() => _OverheatScreenState();
|
||||
}
|
||||
|
||||
class _OverheatScreenState extends State<OverheatScreen> {
|
||||
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<void> _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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
69
pi/ui/lib/services/config_service.dart
Normal file
69
pi/ui/lib/services/config_service.dart
Normal file
@@ -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<String, dynamic>? _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<void> 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<String, dynamic>;
|
||||
}
|
||||
} 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<String, dynamic>?;
|
||||
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<String, dynamic>?;
|
||||
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<String, dynamic>?;
|
||||
final value = overheat?['shutdown_delay_sec'];
|
||||
if (value is int) return Duration(seconds: value);
|
||||
return Duration(seconds: _defaultShutdownDelay);
|
||||
}
|
||||
}
|
||||
77
pi/ui/lib/services/overheat_monitor.dart
Normal file
77
pi/ui/lib/services/overheat_monitor.dart
Normal file
@@ -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();
|
||||
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user