meccatronis commited on
Commit
1fd6502
·
verified ·
1 Parent(s): faedde1

Upload gpu_fan_controller.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. gpu_fan_controller.py +627 -0
gpu_fan_controller.py ADDED
@@ -0,0 +1,627 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Advanced GPU Fan Controller
4
+
5
+ Provides sophisticated fan control with multiple profiles, safety features,
6
+ and comprehensive logging. Supports temperature-based curves, manual override,
7
+ and automatic fallback modes.
8
+ """
9
+
10
+ import time
11
+ import os
12
+ import sys
13
+ import json
14
+ import logging
15
+ import signal
16
+ import argparse
17
+ from typing import Dict, List, Optional, Callable
18
+ from dataclasses import dataclass, asdict
19
+ from enum import Enum
20
+ import threading
21
+ from pathlib import Path
22
+
23
+ from gpu_monitoring import GPUManager, GPUStatus
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ class FanMode(Enum):
29
+ """Fan control modes."""
30
+ AUTO = "auto"
31
+ MANUAL = "manual"
32
+ OFF = "off"
33
+ EMERGENCY = "emergency"
34
+
35
+
36
+ class ProfileType(Enum):
37
+ """Types of fan control profiles."""
38
+ SILENT = "silent"
39
+ BALANCED = "balanced"
40
+ PERFORMANCE = "performance"
41
+ CUSTOM = "custom"
42
+
43
+
44
+ @dataclass
45
+ class FanProfile:
46
+ """Fan control profile configuration."""
47
+ name: str
48
+ profile_type: ProfileType
49
+ description: str
50
+ curve: Dict[str, float]
51
+ safety: Dict[str, float]
52
+ enabled: bool = True
53
+
54
+ def __post_init__(self):
55
+ # Validate curve parameters
56
+ required_curve_keys = ['min_temp', 'max_temp', 'min_pwm', 'max_pwm']
57
+ for key in required_curve_keys:
58
+ if key not in self.curve:
59
+ raise ValueError(f"Missing required curve parameter: {key}")
60
+
61
+ # Validate safety parameters
62
+ required_safety_keys = ['emergency_temp', 'emergency_pwm', 'max_fan_time']
63
+ for key in required_safety_keys:
64
+ if key not in self.safety:
65
+ raise ValueError(f"Missing required safety parameter: {key}")
66
+
67
+
68
+ @dataclass
69
+ class FanStatus:
70
+ """Current fan status."""
71
+ mode: FanMode
72
+ profile: str
73
+ current_pwm: int
74
+ target_pwm: int
75
+ temperature: float
76
+ last_update: float
77
+ manual_override: bool = False
78
+ emergency_mode: bool = False
79
+
80
+
81
+ class FanController:
82
+ """Advanced GPU fan controller with multiple profiles and safety features."""
83
+
84
+ def __init__(self, config_file: str = "config/fan_profiles.json"):
85
+ self.config_file = config_file
86
+ self.profiles = {}
87
+ self.current_profile = None
88
+ self.current_mode = FanMode.AUTO
89
+ self.manual_pwm = 0
90
+ self.running = False
91
+ self.lock = threading.Lock()
92
+
93
+ # GPU management
94
+ self.gpu_manager = GPUManager()
95
+ self.gpu_name = None
96
+
97
+ # Status tracking
98
+ self.status = None
99
+ self.last_status_update = 0
100
+
101
+ # Safety features
102
+ self.emergency_temp = 85.0
103
+ self.emergency_pwm = 255
104
+ self.max_fan_time = 300 # 5 minutes
105
+ self.fan_on_time = 0
106
+
107
+ # Configuration
108
+ self.update_interval = 2.0
109
+ self.log_interval = 30.0
110
+ self.last_log_time = 0
111
+
112
+ # Callbacks
113
+ self.status_callbacks = []
114
+
115
+ # Load configuration
116
+ self.load_profiles()
117
+
118
+ def load_profiles(self):
119
+ """Load fan control profiles from configuration file."""
120
+ try:
121
+ if os.path.exists(self.config_file):
122
+ with open(self.config_file, 'r') as f:
123
+ config_data = json.load(f)
124
+
125
+ for profile_name, profile_data in config_data.items():
126
+ profile = FanProfile(
127
+ name=profile_data['name'],
128
+ profile_type=ProfileType(profile_data['profile_type']),
129
+ description=profile_data['description'],
130
+ curve=profile_data['curve'],
131
+ safety=profile_data['safety'],
132
+ enabled=profile_data.get('enabled', True)
133
+ )
134
+ self.profiles[profile_name] = profile
135
+
136
+ logger.info(f"Loaded {len(self.profiles)} fan profiles")
137
+
138
+ # Set default profile
139
+ if self.profiles:
140
+ default_profile = next(iter(self.profiles.values()))
141
+ self.set_profile(default_profile.name)
142
+ logger.info(f"Set default profile: {default_profile.name}")
143
+
144
+ else:
145
+ # Create default profiles
146
+ self.create_default_profiles()
147
+ self.save_profiles()
148
+
149
+ except Exception as e:
150
+ logger.error(f"Error loading profiles: {e}")
151
+ self.create_default_profiles()
152
+
153
+ def create_default_profiles(self):
154
+ """Create default fan control profiles."""
155
+ self.profiles = {
156
+ "silent": FanProfile(
157
+ name="Silent",
158
+ profile_type=ProfileType.SILENT,
159
+ description="Quiet operation with lower fan speeds",
160
+ curve={
161
+ "min_temp": 40.0,
162
+ "max_temp": 65.0,
163
+ "min_pwm": 120,
164
+ "max_pwm": 220
165
+ },
166
+ safety={
167
+ "emergency_temp": 85.0,
168
+ "emergency_pwm": 255,
169
+ "max_fan_time": 300
170
+ }
171
+ ),
172
+ "balanced": FanProfile(
173
+ name="Balanced",
174
+ profile_type=ProfileType.BALANCED,
175
+ description="Balanced performance and noise",
176
+ curve={
177
+ "min_temp": 38.0,
178
+ "max_temp": 60.0,
179
+ "min_pwm": 155,
180
+ "max_pwm": 255
181
+ },
182
+ safety={
183
+ "emergency_temp": 80.0,
184
+ "emergency_pwm": 255,
185
+ "max_fan_time": 300
186
+ }
187
+ ),
188
+ "performance": FanProfile(
189
+ name="Performance",
190
+ profile_type=ProfileType.PERFORMANCE,
191
+ description="Maximum cooling for high performance",
192
+ curve={
193
+ "min_temp": 35.0,
194
+ "max_temp": 55.0,
195
+ "min_pwm": 180,
196
+ "max_pwm": 255
197
+ },
198
+ safety={
199
+ "emergency_temp": 75.0,
200
+ "emergency_pwm": 255,
201
+ "max_fan_time": 300
202
+ }
203
+ )
204
+ }
205
+
206
+ logger.info("Created default fan profiles")
207
+
208
+ def save_profiles(self):
209
+ """Save current profiles to configuration file."""
210
+ try:
211
+ os.makedirs(os.path.dirname(self.config_file), exist_ok=True)
212
+
213
+ config_data = {}
214
+ for name, profile in self.profiles.items():
215
+ config_data[name] = {
216
+ 'name': profile.name,
217
+ 'profile_type': profile.profile_type.value,
218
+ 'description': profile.description,
219
+ 'curve': profile.curve,
220
+ 'safety': profile.safety,
221
+ 'enabled': profile.enabled
222
+ }
223
+
224
+ with open(self.config_file, 'w') as f:
225
+ json.dump(config_data, f, indent=2)
226
+
227
+ logger.info("Saved fan profiles to configuration file")
228
+
229
+ except Exception as e:
230
+ logger.error(f"Error saving profiles: {e}")
231
+
232
+ def initialize(self) -> bool:
233
+ """Initialize the fan controller."""
234
+ logger.info("Initializing fan controller...")
235
+
236
+ # Initialize GPU manager
237
+ if not self.gpu_manager.initialize():
238
+ logger.error("Failed to initialize GPU manager")
239
+ return False
240
+
241
+ # Get first GPU
242
+ gpus = self.gpu_manager.get_gpu_list()
243
+ if not gpus:
244
+ logger.error("No GPUs detected")
245
+ return False
246
+
247
+ self.gpu_name = gpus[0]
248
+ logger.info(f"Using GPU: {self.gpu_name}")
249
+
250
+ # Check permissions
251
+ if not self.check_permissions():
252
+ logger.error("Insufficient permissions for fan control")
253
+ return False
254
+
255
+ # Initialize fan
256
+ self.set_fan_mode(FanMode.AUTO)
257
+ self.set_pwm(0)
258
+
259
+ logger.info("Fan controller initialized successfully")
260
+ return True
261
+
262
+ def check_permissions(self) -> bool:
263
+ """Check if we have write permissions to fan control files."""
264
+ try:
265
+ gpu_info = self.gpu_manager.get_gpu_info(self.gpu_name)
266
+ if not gpu_info:
267
+ return False
268
+
269
+ hwmon_path = gpu_info[0]['hwmon_path']
270
+ pwm_file = os.path.join(hwmon_path, "pwm1")
271
+ pwm_enable = os.path.join(hwmon_path, "pwm1_enable")
272
+
273
+ # Test write permissions
274
+ with open(pwm_enable, 'w') as f:
275
+ f.write('1')
276
+ with open(pwm_file, 'w') as f:
277
+ f.write('0')
278
+
279
+ return True
280
+
281
+ except Exception as e:
282
+ logger.debug(f"Permission check failed: {e}")
283
+ return False
284
+
285
+ def set_profile(self, profile_name: str) -> bool:
286
+ """Set the current fan control profile."""
287
+ with self.lock:
288
+ if profile_name not in self.profiles:
289
+ logger.error(f"Profile '{profile_name}' not found")
290
+ return False
291
+
292
+ profile = self.profiles[profile_name]
293
+ if not profile.enabled:
294
+ logger.error(f"Profile '{profile_name}' is disabled")
295
+ return False
296
+
297
+ self.current_profile = profile
298
+ logger.info(f"Switched to profile: {profile.name}")
299
+ return True
300
+
301
+ def set_mode(self, mode: FanMode):
302
+ """Set the fan control mode."""
303
+ with self.lock:
304
+ self.current_mode = mode
305
+ logger.info(f"Set fan mode to: {mode.value}")
306
+
307
+ def set_manual_pwm(self, pwm: int):
308
+ """Set manual PWM value (0-255)."""
309
+ with self.lock:
310
+ pwm = max(0, min(255, pwm)) # Clamp to valid range
311
+ self.manual_pwm = pwm
312
+ self.set_mode(FanMode.MANUAL)
313
+ logger.info(f"Set manual PWM to: {pwm}")
314
+
315
+ def set_fan_mode(self, mode: FanMode):
316
+ """Set fan mode and enable/disable fan control."""
317
+ try:
318
+ gpu_info = self.gpu_manager.get_gpu_info(self.gpu_name)
319
+ if not gpu_info:
320
+ return False
321
+
322
+ hwmon_path = gpu_info[0]['hwmon_path']
323
+ fan_enable = os.path.join(hwmon_path, "fan1_enable")
324
+ pwm_enable = os.path.join(hwmon_path, "pwm1_enable")
325
+
326
+ if mode == FanMode.OFF:
327
+ with open(fan_enable, 'w') as f:
328
+ f.write('0')
329
+ with open(pwm_enable, 'w') as f:
330
+ f.write('0')
331
+ else:
332
+ with open(fan_enable, 'w') as f:
333
+ f.write('1')
334
+ with open(pwm_enable, 'w') as f:
335
+ f.write('1')
336
+
337
+ return True
338
+
339
+ except Exception as e:
340
+ logger.error(f"Error setting fan mode: {e}")
341
+ return False
342
+
343
+ def set_pwm(self, pwm: int):
344
+ """Set PWM value (0-255)."""
345
+ try:
346
+ gpu_info = self.gpu_manager.get_gpu_info(self.gpu_name)
347
+ if not gpu_info:
348
+ return False
349
+
350
+ hwmon_path = gpu_info[0]['hwmon_path']
351
+ pwm_file = os.path.join(hwmon_path, "pwm1")
352
+
353
+ pwm = max(0, min(255, pwm)) # Clamp to valid range
354
+
355
+ with open(pwm_file, 'w') as f:
356
+ f.write(str(int(pwm)))
357
+
358
+ return True
359
+
360
+ except Exception as e:
361
+ logger.error(f"Error setting PWM: {e}")
362
+ return False
363
+
364
+ def calculate_target_pwm(self, temperature: float) -> int:
365
+ """Calculate target PWM based on temperature and current profile."""
366
+ if not self.current_profile:
367
+ return 0
368
+
369
+ curve = self.current_profile.curve
370
+ safety = self.current_profile.safety
371
+
372
+ # Emergency temperature handling
373
+ if temperature >= safety['emergency_temp']:
374
+ return int(safety['emergency_pwm'])
375
+
376
+ # Temperature-based curve calculation
377
+ min_temp = curve['min_temp']
378
+ max_temp = curve['max_temp']
379
+ min_pwm = curve['min_pwm']
380
+ max_pwm = curve['max_pwm']
381
+
382
+ if temperature <= min_temp:
383
+ return int(min_pwm)
384
+ elif temperature >= max_temp:
385
+ return int(max_pwm)
386
+ else:
387
+ # Linear interpolation
388
+ temp_range = max_temp - min_temp
389
+ pwm_range = max_pwm - min_pwm
390
+ return int(min_pwm + ((temperature - min_temp) / temp_range) * pwm_range)
391
+
392
+ def check_safety_limits(self, temperature: float, current_pwm: int) -> bool:
393
+ """Check if safety limits are exceeded."""
394
+ if not self.current_profile:
395
+ return False
396
+
397
+ safety = self.current_profile.safety
398
+
399
+ # Emergency temperature check
400
+ if temperature >= safety['emergency_temp']:
401
+ return True
402
+
403
+ # Maximum fan time check
404
+ if current_pwm >= 250: # High fan speed threshold
405
+ self.fan_on_time += self.update_interval
406
+ if self.fan_on_time >= safety['max_fan_time']:
407
+ logger.warning(f"Fan has been at high speed for {safety['max_fan_time']} seconds")
408
+ return True
409
+ else:
410
+ self.fan_on_time = 0
411
+
412
+ return False
413
+
414
+ def update_fan_control(self):
415
+ """Update fan control based on current conditions."""
416
+ try:
417
+ # Get current GPU status
418
+ status_dict = self.gpu_manager.get_status(self.gpu_name)
419
+ gpu_status = status_dict.get(self.gpu_name)
420
+
421
+ if not gpu_status:
422
+ logger.warning("Failed to get GPU status")
423
+ return False
424
+
425
+ temperature = gpu_status.temperature
426
+ current_time = time.time()
427
+
428
+ # Calculate target PWM
429
+ target_pwm = 0
430
+ emergency_mode = False
431
+
432
+ with self.lock:
433
+ if self.current_mode == FanMode.MANUAL:
434
+ target_pwm = self.manual_pwm
435
+ elif self.current_mode == FanMode.OFF:
436
+ target_pwm = 0
437
+ else: # AUTO mode
438
+ target_pwm = self.calculate_target_pwm(temperature)
439
+
440
+ # Check safety limits
441
+ if self.check_safety_limits(temperature, target_pwm):
442
+ target_pwm = int(self.current_profile.safety['emergency_pwm'])
443
+ emergency_mode = True
444
+ self.current_mode = FanMode.EMERGENCY
445
+
446
+ # Apply PWM
447
+ if self.set_pwm(target_pwm):
448
+ # Update status
449
+ self.status = FanStatus(
450
+ mode=self.current_mode,
451
+ profile=self.current_profile.name if self.current_profile else "unknown",
452
+ current_pwm=target_pwm,
453
+ target_pwm=target_pwm,
454
+ temperature=temperature,
455
+ last_update=current_time,
456
+ manual_override=(self.current_mode == FanMode.MANUAL),
457
+ emergency_mode=emergency_mode
458
+ )
459
+
460
+ # Log status periodically
461
+ if current_time - self.last_log_time >= self.log_interval:
462
+ pwm_percent = int(target_pwm * 100 / 255)
463
+ logger.info(f"Temp: {temperature:.1f}°C | PWM: {target_pwm} ({pwm_percent}%) | Mode: {self.current_mode.value}")
464
+ self.last_log_time = current_time
465
+
466
+ # Notify callbacks
467
+ self._notify_status_callbacks()
468
+
469
+ return True
470
+
471
+ except Exception as e:
472
+ logger.error(f"Error updating fan control: {e}")
473
+
474
+ return False
475
+
476
+ def add_status_callback(self, callback: Callable[[FanStatus], None]):
477
+ """Add a callback function to be called when status updates."""
478
+ self.status_callbacks.append(callback)
479
+
480
+ def _notify_status_callbacks(self):
481
+ """Notify all registered status callbacks."""
482
+ if self.status:
483
+ for callback in self.status_callbacks:
484
+ try:
485
+ callback(self.status)
486
+ except Exception as e:
487
+ logger.error(f"Error in status callback: {e}")
488
+
489
+ def run(self):
490
+ """Main control loop."""
491
+ logger.info("Starting fan controller...")
492
+ self.running = True
493
+
494
+ try:
495
+ while self.running:
496
+ self.update_fan_control()
497
+ time.sleep(self.update_interval)
498
+
499
+ except KeyboardInterrupt:
500
+ logger.info("Stopping fan controller...")
501
+ self.running = False
502
+ except Exception as e:
503
+ logger.error(f"Fatal error in fan controller: {e}")
504
+ self.running = False
505
+
506
+ def stop(self):
507
+ """Stop the fan controller."""
508
+ logger.info("Stopping fan controller...")
509
+ self.running = False
510
+
511
+ # Set fan to safe state
512
+ self.set_mode(FanMode.OFF)
513
+ self.set_pwm(0)
514
+
515
+ def get_status(self) -> Optional[FanStatus]:
516
+ """Get current fan status."""
517
+ return self.status
518
+
519
+ def get_profiles(self) -> Dict[str, FanProfile]:
520
+ """Get all available profiles."""
521
+ return self.profiles.copy()
522
+
523
+ def add_profile(self, profile: FanProfile):
524
+ """Add a new fan profile."""
525
+ with self.lock:
526
+ self.profiles[profile.name] = profile
527
+ self.save_profiles()
528
+ logger.info(f"Added profile: {profile.name}")
529
+
530
+ def remove_profile(self, profile_name: str):
531
+ """Remove a fan profile."""
532
+ with self.lock:
533
+ if profile_name in self.profiles:
534
+ del self.profiles[profile_name]
535
+ self.save_profiles()
536
+ logger.info(f"Removed profile: {profile_name}")
537
+
538
+
539
+ class FanControllerCLI:
540
+ """Command-line interface for fan controller."""
541
+
542
+ def __init__(self):
543
+ self.controller = None
544
+
545
+ def setup_logging(self, log_level: str):
546
+ """Setup logging configuration."""
547
+ numeric_level = getattr(logging, log_level.upper(), logging.INFO)
548
+
549
+ logging.basicConfig(
550
+ level=numeric_level,
551
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
552
+ handlers=[
553
+ logging.FileHandler('/var/log/gpu_fan_control.log'),
554
+ logging.StreamHandler(sys.stdout)
555
+ ]
556
+ )
557
+
558
+ def run(self):
559
+ """Run the fan controller with command-line arguments."""
560
+ parser = argparse.ArgumentParser(description='Advanced GPU Fan Controller')
561
+ parser.add_argument('--profile', type=str, help='Fan profile to use')
562
+ parser.add_argument('--manual-pwm', type=int, choices=range(0, 256), help='Manual PWM value (0-255)')
563
+ parser.add_argument('--config', type=str, default='config/fan_profiles.json', help='Configuration file path')
564
+ parser.add_argument('--log-level', choices=['DEBUG', 'INFO', 'WARNING', 'ERROR'], default='INFO', help='Log level')
565
+ parser.add_argument('--list-profiles', action='store_true', help='List available profiles')
566
+ parser.add_argument('--daemon', action='store_true', help='Run as daemon')
567
+
568
+ args = parser.parse_args()
569
+
570
+ # Setup logging
571
+ self.setup_logging(args.log_level)
572
+
573
+ # Initialize controller
574
+ self.controller = FanController(args.config)
575
+
576
+ if args.list_profiles:
577
+ self.list_profiles()
578
+ return
579
+
580
+ if not self.controller.initialize():
581
+ logger.error("Failed to initialize fan controller")
582
+ sys.exit(1)
583
+
584
+ # Apply command-line settings
585
+ if args.profile:
586
+ if not self.controller.set_profile(args.profile):
587
+ logger.error(f"Failed to set profile: {args.profile}")
588
+ sys.exit(1)
589
+
590
+ if args.manual_pwm is not None:
591
+ self.controller.set_manual_pwm(args.manual_pwm)
592
+
593
+ # Setup signal handlers
594
+ signal.signal(signal.SIGINT, self.signal_handler)
595
+ signal.signal(signal.SIGTERM, self.signal_handler)
596
+
597
+ # Run controller
598
+ if args.daemon:
599
+ logger.info("Running as daemon...")
600
+ self.controller.run()
601
+ else:
602
+ try:
603
+ self.controller.run()
604
+ except KeyboardInterrupt:
605
+ logger.info("Received interrupt signal")
606
+
607
+ def list_profiles(self):
608
+ """List available fan profiles."""
609
+ controller = FanController()
610
+ controller.load_profiles()
611
+
612
+ print("Available fan profiles:")
613
+ for name, profile in controller.profiles.items():
614
+ status = "✓" if profile.enabled else "✗"
615
+ print(f" {status} {name}: {profile.description}")
616
+
617
+ def signal_handler(self, signum, frame):
618
+ """Handle shutdown signals."""
619
+ logger.info(f"Received signal {signum}, shutting down...")
620
+ if self.controller:
621
+ self.controller.stop()
622
+ sys.exit(0)
623
+
624
+
625
+ if __name__ == "__main__":
626
+ cli = FanControllerCLI()
627
+ cli.run()