From 15a805f7fe708a35c0baac6224c3c2685ae58293 Mon Sep 17 00:00:00 2001 From: Mikkeli Matlock Date: Sun, 25 Jan 2026 19:53:43 +0900 Subject: [PATCH] overheat monitor with auto poweroff - needs passwordless sudo on target machine or deploy to run as root --- pi/ui/config.json | 7 + pi/ui/lib/app_root.dart | 35 ++++- pi/ui/lib/screens/overheat_screen.dart | 152 ++++++++++++++++++++++ pi/ui/lib/services/config_service.dart | 69 ++++++++++ pi/ui/lib/services/overheat_monitor.dart | 77 +++++++++++ scripts/__pycache__/build.cpython-39.pyc | Bin 3577 -> 0 bytes scripts/__pycache__/deploy.cpython-39.pyc | Bin 2738 -> 0 bytes scripts/deploy.py | 14 ++ scripts/deploy.sh | 11 ++ 9 files changed, 362 insertions(+), 3 deletions(-) create mode 100644 pi/ui/config.json create mode 100644 pi/ui/lib/screens/overheat_screen.dart create mode 100644 pi/ui/lib/services/config_service.dart create mode 100644 pi/ui/lib/services/overheat_monitor.dart delete mode 100644 scripts/__pycache__/build.cpython-39.pyc delete mode 100644 scripts/__pycache__/deploy.cpython-39.pyc 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 94c3e1a6501082d5394dd5e36ed5dc80d3f31ca7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3577 zcmZuz&2QVt73Vi8S(Y8gcI+g(JKGk`DhXv^8w5qV4mMHZuDfw;$ab3&C+tI`=d3#;?=9G!cic=L3bEwBYT4{{6CdSQBtbdg>F&IPtamku@l z4|d_XqAFC|R$3Pe?`|H&lw=`~hJx&+oNNyQF32|H=@YUR=Rz<}cGuhWdN)sqIEXS5 zCFFP84?ZVPB5^?WDiN2(QIbCeUM7M#X0%=Z5iF%VY^kNO6^H{*8%Ca%N2S@rFP2ud zp)>iH7^Dl%sws3U}awD{ZQG9~83rLs_20=ov z6ZSL`Bu&U(5XCuX?c9Rz5c01-|CPK}3CH8K?9v?aC=sR6Pot!)XL)bP(~xCZsq-8N zle4Fkm3sCpD-G1z(zy-0OY87S!1pt}VV1|@?+QN;-1Qu-P-!A4s`rLAKJOf`@Q_4% z?*&Lkg2!Ogpck|2q_PkhLV&bA^T|OJ9=vY89a1|j&1;6i5W;}@X)cC27+kQYLOP+i za=+W%?B06!Gf9fk455V4x^-vgtKy^eDl`c?mXlDP?^&Ajiak^B0QOi(y&X=Vaa3mh_sEOc@8 zm`9;`c?y4WVZTa=kAagd!b8yv)lxN8`=4djRR{h{;9~v>aNhGQhLrT|Phf(z2R$m^ z=C{_7^z~T7{D6p8z(ZDXbDIe=Is5?c{W#zedL@%(;7)hr<_!|^G|ODbiXnJRL}}74 z7J`6>2RCoHGU46*BzO12@Pp)Q*NXY~r33yUYD`;}r*%4|-g)%M)9!RV?ZG{7wzF~P z>-&Dk?`&>ttv|Tm_2wp&M~_ZvkWm-m*8CDYN-H1IKrkpf4qVQMOJ(m`eIaAQmpAjP zn=v0mUD%zWlN23He)4~9IgnF71?n1 zl_M2+`sj?N15E>(F@nrnR!;NE7@I;xe+d;);Yc6pW9wMSh9FyIq`eO~@xA=cNa242 zIYbOZ5I~J1V`QK_&=oo*&*F@0BSUIbXhTv^!*rzqq?$+r%FOrbktynQ7PMF+OKSNq z@Xw8`lhcjN@f4k_)>5NAGNr}_N`IsB-wFj$26mm7bcN1~#>sBc7*8K7_mn?eJ+epk zxOuGfXO0|@T^QN@*;fi(969vDD}ye5C$)Z~{_*E@c^kD|JkvriZ7cLL#PUbQN`*~~ z$m&QYRk%&M5G}Sid&?xce5zjhskW` zjXYrd7U2Xu&ao*dK3h*B5e0EnfGnB7Ae;bo5J>(!#LCrfw_7&HdhJ;uXAq&K+Af-| zJCSsgV8Fa-S@C7T_w%SQT{q?XMH8a5ea7KBej5R>xX{6P#P4=CfDtqS7rbvN8!-`ya+AUp(J zXIH6CBCT-Vbp^l~6E0SVRSD|F@+on$)fLZ%Y<0gkD6XEeCMn2WtdlWYeXn2?HP=1q z4(h|m2PEZbDvCMj9kLaX2?ZQ=?o{oBwpuKrt1I_*ppL~%FoJ$|HYJw8CXK+j1-O2_ zut@KjVA)Rb=>%4)jTP*WGf)WxS$o(zSHXa_yYAmx@5;g~ONA^)P%I8;#G%o78f7Ar z*a4_;kc%kxEItsNv9ht%-Tck{&W_*R+}tUxV2I8t8+UitAKdddcXzgScS<{g77R5R z&{~Po&V-*u1uG2OM4Ao;@*AsqTSt z(D_cKl>Yq{=*rgMp=gfU)GayYw7QOfa6DIbpSA&HvqUk zRt>GGzJ**E$)`QZSFkwgNnWaxUIbChq!zVb8i-tD{TM5#F;e>`bwElJ*4Q3t{5Gv6 z8uTSl!6@R})WhO(<+~5}vw#m%lvPa?z*=FuuB`2a=7!M8i4>X*$X!-^DpTyu{31OD znKG$0kT-w}RuCdMmc{{#5$n+rx2US(RK;(`i@H(o0K9Kro;Tc(AHy?>MeB20@hk3BFw+8ra`}f7MDj8X($j;FXT= zqnz)z)V0=fY5P7+L*M7W1ZD-PS8*m=A3Q^8V|2w)k86|ddr{1MUmgmsgk5}=#*dj- z-|lqRw{~P&TtgjK@PQCp8Zo2;^uvM!x=#|4Le0g(Ts2tO;K)2Gjb56@l_5?Jj6n}a wJZq%nOm z>j<9H|ETy6@b+)>@!{ja$7j&wKcQo2h!|!h!9#3EG9-3%hK?QGp=(EP=)veD{-idn zVZ>`ZxWMDNtNI%>Z1Bc-ZrJ1v=5C_lJZ~~@6EPoJ4O#$g4q6>r1N1Gn!9U^)>xeIM zXS~GbUpuVDK6>d5Z}DZe@EWs4w)D~+-e#L%vB;KR6Ly=efPRIovbC3JxXRbq`g0=w z!B;O3MvU}P`%ZE6m`{`JjLMOSr-~kBg7znoP_)lQc1m}XTq!Q-7kk}${UDboT++il zP8jqlOE^^-J&0+PGMY_QoTX8coKeB0f;A+ST)c=!yjy<@UPI5?Si1+2Iw_Wz-8BTO zW3IYmnWdUcV@>i{dv<;8^CV95FSQqm$!jA!I;_|F(T;})%6 zp3;bpvdILl*Uf!cfYN{c>96!1rlJ8~ZC86!5vNMK<19{fUFL^Vk&U>N+7Wr$c7y?y zwblOfQ@E`BI}*1*_U``HBvo7EEyiDLN#pPi_$oWyD&rtQZo<-?o{2>;zYR^g&>^yn zt)z`b6Fx^qcKu*@383vjlYfLxp)33b$@>b>snGiyv-0N7mCKwr#s{847x*t5=ScZi zHRGM%A%d9eA{Sja81>PQkv#YjpvL@u4~@CtBd_@G(=1}NJS#1O2@Xq-;^SOI1`gfg z?vq|`zqd_yte76f0Gc#2NCNO z-PqZGw!8PMaCh(N6M)K>u~bsDK&c&hCbbI^h)Oo)Da6L0%T3^{a<#=WHdzglrnPt3yx|8khb>%^_v^96OZV6_HZvstdoyck=H0CBvRgBsEtisG$FApH z`RumEN~v8jTDE%W96PMR)?N{e(ob(zTeovC+pzm?TH8C9cGnx5?-(0)7jX1(aj#+_ z%O+jgv$-tkK;+!uLFOZqR9)@nkR{qZ$)wT^!6%vG;S>l(H!G$I(`drq;m?YVhYufq zgUPBLKwJa^OFP?;$lFEG&vG&1fW)6xNd4UM8;b=WsZ5-`M=Kl*D8bSbBql&vNY&jf z7W!xDC{B+pwq>{5)t-PFqw(f;kW6~k@BQi zl{w2O;8^j=NF~>FO5YlCD;D1?0F5e_v}n*yC(Sz3=$H#22E$E7ySgy&7i5p(T{_5a zR;o69K!K&U3!hThmKN*ulmD~xJN!v;Z(kU5s!n1c_Oe`@#_9z2uJVE&CDHMqU9$;O zX5-xFU+g`796sLbiB&k2r7rENc*3^ z2Uhq7wK^a$1;8fwui-_Zu`T_t@Hr&Xm@tRA%ws;Qy>d-znFKw@3Q~RSu>h=yrB)&y zvALA6x=AGl#M*8=E7qz|eOP#HCIDYG7%zPdWLnJKxVj)60rdv($wLKMQrxfL1mWQo zbl5enXmn(1c#7^D>>S?7Lc;WM%SuD{&w`-Nff7jm?4*-L%4}u_z_IL z_Pu~KvG_Sibr6QeE)3gv(8jy%bzKWXmW{$td+ut9E6|Ic&FKv{!C_7P|Ddx)DEP%BV|l_KnR7}Co$FQj1ynYj RmPIUeHvCp&sT0fx_ 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