overheat monitor with auto poweroff

- needs passwordless sudo on target machine or deploy to run as root
This commit is contained in:
Mikkeli Matlock
2026-01-25 19:53:43 +09:00
parent 22e9cd2c43
commit 15a805f7fe
9 changed files with 362 additions and 3 deletions

7
pi/ui/config.json Normal file
View File

@@ -0,0 +1,7 @@
{
"overheat": {
"threshold_celsius": 75.0,
"trigger_duration_sec": 10,
"shutdown_delay_sec": 10
}
}

View File

@@ -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,
);
}
}

View 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,
),
),
],
),
),
);
}
}

View 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);
}
}

View 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();