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/splash_screen.dart';
|
||||||
import 'screens/dashboard_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
|
/// Root widget that manages app state transitions
|
||||||
class AppRoot extends StatefulWidget {
|
class AppRoot extends StatefulWidget {
|
||||||
@@ -13,6 +16,7 @@ class AppRoot extends StatefulWidget {
|
|||||||
|
|
||||||
class _AppRootState extends State<AppRoot> {
|
class _AppRootState extends State<AppRoot> {
|
||||||
bool _initialized = false;
|
bool _initialized = false;
|
||||||
|
bool _overheatTriggered = false;
|
||||||
String _initStatus = 'Starting...';
|
String _initStatus = 'Starting...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -21,7 +25,17 @@ class _AppRootState extends State<AppRoot> {
|
|||||||
_runInitSequence();
|
_runInitSequence();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
OverheatMonitor.instance.stop();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _runInitSequence() async {
|
Future<void> _runInitSequence() async {
|
||||||
|
// Load config first
|
||||||
|
setState(() => _initStatus = 'Loading config...');
|
||||||
|
await ConfigService.instance.load();
|
||||||
|
|
||||||
// Simulate init checks - replace with real checks later
|
// Simulate init checks - replace with real checks later
|
||||||
// (UART, GPS, sensors, etc.)
|
// (UART, GPS, sensors, etc.)
|
||||||
|
|
||||||
@@ -37,16 +51,31 @@ class _AppRootState extends State<AppRoot> {
|
|||||||
setState(() => _initStatus = 'Ready');
|
setState(() => _initStatus = 'Ready');
|
||||||
await Future.delayed(const Duration(milliseconds: 300));
|
await Future.delayed(const Duration(milliseconds: 300));
|
||||||
|
|
||||||
|
// Start overheat monitoring
|
||||||
|
OverheatMonitor.instance.start(
|
||||||
|
onOverheat: () {
|
||||||
|
setState(() => _overheatTriggered = true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
setState(() => _initialized = true);
|
setState(() => _initialized = true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return AnimatedSwitcher(
|
||||||
duration: const Duration(milliseconds: 500),
|
duration: const Duration(milliseconds: 500),
|
||||||
child: _initialized
|
child: child,
|
||||||
? const DashboardScreen(key: ValueKey('dashboard'))
|
|
||||||
: SplashScreen(key: const ValueKey('splash'), status: _initStatus),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
PROJECT_ROOT = SCRIPT_DIR.parent
|
||||||
CONFIG_FILE = SCRIPT_DIR / "deploy_target.json"
|
CONFIG_FILE = SCRIPT_DIR / "deploy_target.json"
|
||||||
BUILD_DIR = PROJECT_ROOT / "pi" / "ui" / "build" / "elinux" / "arm64" / "release" / "bundle"
|
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:
|
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/",
|
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
|
# Restart service if requested
|
||||||
if restart:
|
if restart:
|
||||||
print()
|
print()
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ SERVICE_NAME=$(read_json service_name)
|
|||||||
|
|
||||||
SSH_TARGET="$PI_USER@$PI_HOST"
|
SSH_TARGET="$PI_USER@$PI_HOST"
|
||||||
BUILD_DIR="$PROJECT_ROOT/pi/ui/build/elinux/arm64/release/bundle"
|
BUILD_DIR="$PROJECT_ROOT/pi/ui/build/elinux/arm64/release/bundle"
|
||||||
|
CONFIG_SRC="$PROJECT_ROOT/pi/ui/config.json"
|
||||||
|
|
||||||
echo "=== Smart Serow Deploy ==="
|
echo "=== Smart Serow Deploy ==="
|
||||||
echo "Target: $SSH_TARGET:$REMOTE_PATH"
|
echo "Target: $SSH_TARGET:$REMOTE_PATH"
|
||||||
@@ -44,6 +45,16 @@ rsync -avz --delete \
|
|||||||
"$BUILD_DIR/" \
|
"$BUILD_DIR/" \
|
||||||
"$SSH_TARGET:$REMOTE_PATH/bundle/"
|
"$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 service if requested
|
||||||
RESTART="${1:-}"
|
RESTART="${1:-}"
|
||||||
if [ "$RESTART" = "--restart" ] || [ "$RESTART" = "-r" ]; then
|
if [ "$RESTART" = "--restart" ] || [ "$RESTART" = "-r" ]; then
|
||||||
|
|||||||
Reference in New Issue
Block a user