Coverage for security / system_requirements.py: 85.6%
271 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2System Requirements - Hardware detection, tier classification, and adaptive feature gating.
4The HART OS equilibrium layer. Runs early in the boot sequence to detect
5actual hardware capabilities and auto-configure features to match what this
6node can sustain.
8Philosophy:
9 Every node is net-positive. A Raspberry Pi Zero running gossip matters.
10 A 64-core GPU server generating video matters. The system finds equilibrium:
11 each node auto-adapts to what it can sustain, and the network as a whole is
12 always net positive. No node is punished for being small. No node is
13 penalized for being powerful. Every drop counts.
15Contribution Tiers (what you CAN contribute, not what you're excluded from):
17 EMBEDDED - Any device that can run Python - sensors, GPIO, serial, MQTT bridge
18 OBSERVER - < 2 cores / < 4 GB - gossip-only, audit witness, Flask server
19 LITE - 2 cores, 4 GB RAM, 1 GB disk - chat + gossip + audit
20 STANDARD - 4 cores, 8 GB RAM, 10 GB disk - + TTS, Whisper, agents
21 FULL - 8 cores, 16 GB RAM, 50 GB disk, 8 GB VRAM - + video, media, local 7B LLM
22 COMPUTE_HOST - 16 cores, 32 GB RAM, 100 GB disk, 12 GB VRAM - regional host, local 13B+ LLM
24Mathematical Derivation (from vram_manager.py VRAM_BUDGETS + model disk sizes):
26 EMBEDDED: Python(0.05) + gossip(0.05) + adapters(0.02) = ~120 MB → no floor
27 OBSERVER: OS(1) + Flask(0.5) + gossip(0.2) = 1.7 GB RAM → floor 2 GB
28 LITE: OS(1) + Flask(0.5) + cloud_chat(0.5) + relay(0.5) = 3 GB → 4 GB
29 STANDARD: OS(1) + Flask(0.5) + Whisper_CPU(0.5) + TTS_CPU(2)
30 + agents(1.5) + gossip(0.5) = 6.5 GB → 8 GB
31 Disk: code(2) + whisper_base(0.15) + TTS(3) + recipes(2) = 7.15 GB → 10 GB
32 FULL: OS(1) + Flask(0.5) + Ollama_7B(5) + agents(1.5)
33 + gossip(0.5) + GPU_overhead(2) = 10.5 GB → 16 GB
34 Disk: code(2) + Ollama_7B(4) + MiniCPM(4) + LTX-2(27)
35 + TTS(3) + Whisper(3) + cache(5) = 48 GB → 50 GB
36 VRAM: Wan2GP(8) OR MiniCPM(6) + Whisper(2) → 8 GB
37 COMPUTE_HOST: OS(1) + Flask(0.5) + Ollama_13B(9) + agents(2)
38 + peer_serving(3) + gossip(0.5) = 16 GB → 32 GB
39 Disk: all models(48) + Ollama_13B(8) + serving_cache(20)
40 + logs(5) = 81 GB → 100 GB
41 VRAM: 13B on GPU OR multiple tools concurrent → 12 GB
43Usage:
44 from security.system_requirements import run_system_check, get_capabilities
45 caps = run_system_check() # Called once at boot
46 caps.tier # NodeTierLevel.STANDARD
47 caps.enabled_features # ['agent_engine', 'coding_agent', 'tts', 'whisper']
48"""
50import logging
51import os
52import platform
53import shutil
54import socket
55import threading
56import time
57from dataclasses import dataclass, field
58from enum import Enum
59from typing import Dict, List, Optional, Tuple
61logger = logging.getLogger('hevolve_security')
63# Allow tier override for testing / dev deployments
64FORCE_TIER_ENV = 'HEVOLVE_FORCE_TIER'
67# ═══════════════════════════════════════════════════════════════
68# GLOSSARY — Three Distinct Classification Systems
69# ═══════════════════════════════════════════════════════════════
70#
71# 1. CAPABILITY TIER (NodeTierLevel, this file)
72# Values: embedded, observer, lite, standard, full, compute_host
73# Purpose: What a node CAN do based on hardware (CPU, RAM, VRAM, disk)
74# Set by: Auto-detected via classify_tier(), or HEVOLVE_FORCE_TIER override
75# Stored: PeerNode.capability_tier (models.py)
76# API: get_tier(), get_tier_name(), get_capabilities()
77#
78# 2. TOPOLOGY MODE (key_delegation.py get_node_tier())
79# Values: flat, regional, central
80# Purpose: Where a node sits in the network hierarchy
81# Set by: HEVOLVE_NODE_TIER env var (name is legacy — it means topology)
82# Stored: PeerNode.tier (models.py)
83# API: key_delegation.get_node_tier()
84#
85# 3. MODEL TIER (model_registry.py ModelTier)
86# Values: fast, balanced, expert
87# Purpose: LLM backend performance/cost classification
88# Set by: Model registration in model_registry
89# API: ModelRegistry.select_backend()
90#
91# These are INDEPENDENT dimensions. A node can be:
92# capability_tier=standard + topology=flat + using model_tier=expert
93# Do NOT mix or use interchangeably.
94# ═══════════════════════════════════════════════════════════════
96# ═══════════════════════════════════════════════════════════════
97# Types
98# ═══════════════════════════════════════════════════════════════
100class NodeTierLevel(Enum):
101 """Capability tier — what this node can offer the network based on hardware.
103 NOT to be confused with topology mode (flat/regional/central) which
104 describes network position, or model tier (fast/balanced/expert) which
105 classifies LLM backends.
106 """
107 EMBEDDED = "embedded" # Any device - sensors, GPIO, serial, MQTT bridge
108 OBSERVER = "observer" # Below lite - still gossips, still audits, Flask
109 LITE = "lite" # Basic chat + gossip + audit + storage relay
110 STANDARD = "standard" # + TTS, Whisper, coding agent, goal engine
111 FULL = "full" # + Video gen, media agent, full model registry
112 COMPUTE_HOST = "compute_host" # Can serve as regional host for other nodes
115# Ordered from highest to lowest for classification
116_TIER_ORDER = [
117 NodeTierLevel.COMPUTE_HOST,
118 NodeTierLevel.FULL,
119 NodeTierLevel.STANDARD,
120 NodeTierLevel.LITE,
121 NodeTierLevel.OBSERVER,
122 NodeTierLevel.EMBEDDED,
123]
125# Numeric rank for comparison (higher = more capable)
126_TIER_RANK = {t: i for i, t in enumerate(reversed(_TIER_ORDER))}
129@dataclass
130class TierRequirement:
131 """Hardware thresholds for a contribution tier."""
132 tier: NodeTierLevel
133 min_cpu_cores: int
134 min_ram_gb: float
135 min_disk_gb: float
136 min_gpu_vram_gb: float = 0.0
139@dataclass
140class HardwareProfile:
141 """Detected hardware capabilities of this node."""
142 cpu_cores: int = 0
143 cpu_model: str = ''
144 ram_gb: float = 0.0
145 disk_free_gb: float = 0.0
146 disk_total_gb: float = 0.0
147 gpu_name: Optional[str] = None
148 gpu_vram_gb: float = 0.0
149 cuda_available: bool = False
150 os_platform: str = ''
151 python_version: str = ''
152 network_reachable: bool = False
153 # Embedded / hardware I/O detection
154 is_read_only_fs: bool = False
155 has_gpio: bool = False
156 has_serial: bool = False
157 has_camera_hw: bool = False
158 has_imu: bool = False
159 has_gps: bool = False
160 has_lidar: bool = False
161 detected_at: float = field(default_factory=time.time)
163 def to_dict(self) -> Dict:
164 return {
165 'cpu_cores': self.cpu_cores,
166 'cpu_model': self.cpu_model,
167 'ram_gb': round(self.ram_gb, 2),
168 'disk_free_gb': round(self.disk_free_gb, 2),
169 'disk_total_gb': round(self.disk_total_gb, 2),
170 'gpu_name': self.gpu_name,
171 'gpu_vram_gb': round(self.gpu_vram_gb, 2),
172 'cuda_available': self.cuda_available,
173 'os_platform': self.os_platform,
174 'python_version': self.python_version,
175 'network_reachable': self.network_reachable,
176 'is_read_only_fs': self.is_read_only_fs,
177 'has_gpio': self.has_gpio,
178 'has_serial': self.has_serial,
179 'has_camera_hw': self.has_camera_hw,
180 'has_imu': self.has_imu,
181 'has_gps': self.has_gps,
182 'has_lidar': self.has_lidar,
183 }
186@dataclass
187class NodeCapabilities:
188 """Resolved capabilities: tier + hardware + enabled features."""
189 tier: NodeTierLevel
190 hardware: HardwareProfile
191 enabled_features: List[str]
192 disabled_features: Dict[str, str] # feature → reason
193 env_vars_set: Dict[str, str] # env var → value that was set
194 detected_at: float = field(default_factory=time.time)
196 def to_dict(self) -> Dict:
197 return {
198 'tier': self.tier.value,
199 'hardware': self.hardware.to_dict(),
200 'enabled_features': self.enabled_features,
201 'disabled_features': self.disabled_features,
202 'env_vars_set': self.env_vars_set,
203 }
206# ═══════════════════════════════════════════════════════════════
207# Model resource table (source of truth for tier derivation)
208# ═══════════════════════════════════════════════════════════════
209# Each entry: (min_vram_gb, model_disk_gb, ram_on_cpu_gb)
210# From vram_manager.py VRAM_BUDGETS + measured model sizes.
212MODEL_RESOURCE_TABLE = {
213 'whisper_base': (0.0, 0.15, 0.5), # CPU-safe, 140MB model
214 'whisper_large': (2.0, 3.0, 0.0), # GPU only
215 'tts_audio_suite': (4.0, 3.0, 2.0), # 4GB VRAM or 2GB CPU
216 'acestep_music': (6.0, 6.0, 0.0), # GPU only
217 'minicpm_vision': (6.0, 4.0, 0.0), # GPU only
218 'ltx2_video': (6.0, 27.0, 0.0), # 27GB disk (fp8 weights)
219 'wan2gp_video': (8.0, 8.0, 0.0), # 8GB VRAM minimum
220 'ollama_7b_q4': (4.5, 4.0, 5.0), # 7B quantised, CPU or GPU
221 'ollama_13b_q4': (8.0, 8.0, 9.0), # 13B quantised
222}
225# ═══════════════════════════════════════════════════════════════
226# Tier requirements (checked highest → lowest)
227# Derived deterministically from MODEL_RESOURCE_TABLE above.
228# ═══════════════════════════════════════════════════════════════
230TIER_REQUIREMENTS: List[TierRequirement] = [
231 TierRequirement(NodeTierLevel.COMPUTE_HOST, 16, 32.0, 100.0, 12.0),
232 # RAM lowered from 16.0 to 15.0: 16 GB physical DIMMs report ~15.7 GB
233 # after BIOS/hardware reservation. The old 16.0 threshold excluded
234 # every standard 16 GB laptop from FULL tier — the exact machines
235 # that HAVE 8 GB VRAM and should qualify. Disk lowered from 20.0 to
236 # 15.0: models can be downloaded on-demand (auto-download with
237 # progress), so 50 GB upfront is not a hard requirement.
238 TierRequirement(NodeTierLevel.FULL, 8, 15.0, 15.0, 8.0),
239 TierRequirement(NodeTierLevel.STANDARD, 4, 8.0, 2.0, 0.0),
240 TierRequirement(NodeTierLevel.LITE, 2, 4.0, 1.0, 0.0),
241 TierRequirement(NodeTierLevel.OBSERVER, 1, 2.0, 0.0, 0.0),
242 # EMBEDDED has no requirements - it's the floor. Any device that runs Python.
243]
246# ═══════════════════════════════════════════════════════════════
247# Feature-to-tier mapping
248# ═══════════════════════════════════════════════════════════════
250# (minimum_tier, env_var_name)
251FEATURE_TIER_MAP: Dict[str, Tuple[NodeTierLevel, str]] = {
252 # Embedded tier - any device that runs Python
253 'gossip': (NodeTierLevel.EMBEDDED, 'HEVOLVE_GOSSIP_ENABLED'),
254 'sensor_bridge': (NodeTierLevel.EMBEDDED, 'HEVOLVE_SENSOR_BRIDGE_ENABLED'),
255 'sensor_fusion': (NodeTierLevel.EMBEDDED, 'HEVOLVE_SENSOR_FUSION_ENABLED'),
256 'protocol_adapter': (NodeTierLevel.EMBEDDED, 'HEVOLVE_PROTOCOL_ADAPTER_ENABLED'),
257 # Observer tier - minimal server
258 'flask_server': (NodeTierLevel.OBSERVER, 'HEVOLVE_FLASK_ENABLED'),
259 # Lite tier - cloud-backed chat
260 'vision_lightweight': (NodeTierLevel.LITE, 'HEVOLVE_VISION_LITE_ENABLED'),
261 # Standard tier - full agent capabilities
262 'agent_engine': (NodeTierLevel.STANDARD, 'HEVOLVE_AGENT_ENGINE_ENABLED'),
263 'coding_agent': (NodeTierLevel.STANDARD, 'HEVOLVE_CODING_AGENT_ENABLED'),
264 'coding_aggregator': (NodeTierLevel.STANDARD, 'HEVOLVE_CODING_AGGREGATOR_ENABLED'),
265 'vlm_computer_use': (NodeTierLevel.STANDARD, 'HEVOLVE_VLM_COMPUTER_USE_ENABLED'),
266 'tts': (NodeTierLevel.STANDARD, 'HEVOLVE_TTS_ENABLED'),
267 'whisper': (NodeTierLevel.STANDARD, 'HEVOLVE_WHISPER_ENABLED'),
268 # Lite tier - cloud-backed services
269 'crawl4ai': (NodeTierLevel.LITE, 'HEVOLVE_CRAWL4AI_ENABLED'),
270 # Standard tier — features that need a local LLM but NOT full GPU
271 # The 0.8B draft classifier needs ~550 MB VRAM, well within STANDARD's
272 # budget. local_llm enables the 2B/4B llama-server which also runs at
273 # STANDARD (4-8 GB VRAM, handled by the model lifecycle's eviction
274 # policy). These were incorrectly gated at FULL, which blocked the
275 # entire draft-first architecture on every 16 GB laptop because the
276 # OS reports ~15.7 GB (below the old 16.0 threshold). See T20 #163.
277 'speculative_dispatch': (NodeTierLevel.STANDARD, 'HEVOLVE_SPECULATIVE_ENABLED'),
278 'local_llm': (NodeTierLevel.STANDARD, 'HEVOLVE_LOCAL_LLM_ENABLED'),
279 # Full tier - heavy GPU workloads (video gen, media agent, large VLMs)
280 'video_gen': (NodeTierLevel.FULL, 'HEVOLVE_VIDEO_GEN_ENABLED'),
281 'media_agent': (NodeTierLevel.FULL, 'HEVOLVE_MEDIA_AGENT_ENABLED'),
282 'vlm_omniparser': (NodeTierLevel.FULL, 'HEVOLVE_VLM_OMNIPARSER_ENABLED'),
283 'minicpm_vision': (NodeTierLevel.FULL, 'HEVOLVE_MINICPM_ENABLED'),
284 # video_captioning uses the 0.8B caption server (same as draft
285 # classifier) — needs only 550MB VRAM, runs at STANDARD tier.
286 # MiniCPM stays at FULL for richer captioning when VRAM allows.
287 'video_captioning': (NodeTierLevel.STANDARD, 'HEVOLVE_VIDEO_CAPTIONING_ENABLED'),
288 # Compute host tier - regional hosting
289 'local_llm_large': (NodeTierLevel.COMPUTE_HOST, 'HEVOLVE_LOCAL_LLM_LARGE_ENABLED'),
290 'regional_host': (NodeTierLevel.COMPUTE_HOST, 'HEVOLVE_REGIONAL_HOST_ELIGIBLE'),
291}
294# ═══════════════════════════════════════════════════════════════
295# Hardware detection
296# ═══════════════════════════════════════════════════════════════
298def detect_hardware() -> HardwareProfile:
299 """Probe CPU, RAM, disk, GPU, and network.
301 Uses psutil for RAM (with platform-specific fallback).
302 Reuses VRAMManager for GPU detection (no duplicate logic).
303 """
304 hw = HardwareProfile()
305 hw.os_platform = platform.system()
306 hw.python_version = platform.python_version()
308 # CPU
309 hw.cpu_cores = os.cpu_count() or 1
310 # platform.processor() → platform.uname() → WMI on Windows, which can
311 # hang 30+ minutes on cold boot. Run it with a timeout.
312 hw.cpu_model = ''
313 try:
314 import concurrent.futures as _cf
315 _ex = _cf.ThreadPoolExecutor(max_workers=1)
316 hw.cpu_model = _ex.submit(lambda: platform.processor() or '').result(timeout=5)
317 _ex.shutdown(wait=False) # Don't block if thread is stuck on WMI
318 except Exception:
319 try:
320 _ex.shutdown(wait=False)
321 except Exception:
322 pass
323 pass
325 # RAM
326 hw.ram_gb = _detect_ram_gb()
328 # Disk (check the code root or home directory)
329 hw.disk_free_gb, hw.disk_total_gb = _detect_disk_gb()
331 # GPU (reuse existing VRAMManager)
332 try:
333 from integrations.service_tools.vram_manager import vram_manager
334 gpu_info = vram_manager.detect_gpu()
335 hw.cuda_available = gpu_info.get('cuda_available', False)
336 hw.gpu_vram_gb = gpu_info.get('total_gb', 0.0)
337 hw.gpu_name = gpu_info.get('name')
338 except Exception:
339 pass
341 # Network
342 hw.network_reachable = check_network_connectivity()
344 # Embedded / hardware I/O
345 hw.is_read_only_fs = _detect_read_only_fs()
346 hw.has_gpio = _detect_gpio()
347 hw.has_serial = _detect_serial()
348 hw.has_camera_hw = _detect_camera_hw()
349 hw.has_imu = _detect_imu()
350 hw.has_gps = _detect_gps()
351 hw.has_lidar = _detect_lidar()
353 hw.detected_at = time.time()
354 return hw
357def _detect_ram_gb() -> float:
358 """Detect total RAM in GB. Uses psutil with platform fallback."""
359 # Try psutil first (most reliable cross-platform)
360 try:
361 import psutil
362 return round(psutil.virtual_memory().total / (1024 ** 3), 2)
363 except ImportError:
364 pass
366 # Fallback: platform-specific
367 system = platform.system()
368 try:
369 if system == 'Linux':
370 with open('/proc/meminfo', 'r') as f:
371 for line in f:
372 if line.startswith('MemTotal:'):
373 kb = int(line.split()[1])
374 return round(kb / (1024 ** 2), 2)
375 elif system == 'Windows':
376 import ctypes
377 class MEMORYSTATUSEX(ctypes.Structure):
378 _fields_ = [
379 ("dwLength", ctypes.c_ulong),
380 ("dwMemoryLoad", ctypes.c_ulong),
381 ("ullTotalPhys", ctypes.c_ulonglong),
382 ("ullAvailPhys", ctypes.c_ulonglong),
383 ("ullTotalPageFile", ctypes.c_ulonglong),
384 ("ullAvailPageFile", ctypes.c_ulonglong),
385 ("ullTotalVirtual", ctypes.c_ulonglong),
386 ("ullAvailVirtual", ctypes.c_ulonglong),
387 ("ullAvailExtendedVirtual", ctypes.c_ulonglong),
388 ]
389 stat = MEMORYSTATUSEX()
390 stat.dwLength = ctypes.sizeof(stat)
391 ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(stat))
392 return round(stat.ullTotalPhys / (1024 ** 3), 2)
393 elif system == 'Darwin':
394 import subprocess
395 result = subprocess.run(
396 ['sysctl', '-n', 'hw.memsize'],
397 capture_output=True, text=True, timeout=5)
398 if result.returncode == 0:
399 return round(int(result.stdout.strip()) / (1024 ** 3), 2)
400 except Exception as e:
401 logger.debug(f"RAM detection fallback failed: {e}")
403 # Last resort: assume 4 GB (conservative, won't over-promise)
404 return 4.0
407def _detect_disk_gb() -> Tuple[float, float]:
408 """Detect free and total disk space in GB for the code root."""
409 try:
410 code_root = os.environ.get('HEVOLVE_CODE_ROOT',
411 os.path.dirname(os.path.dirname(
412 os.path.abspath(__file__))))
413 usage = shutil.disk_usage(code_root)
414 free_gb = round(usage.free / (1024 ** 3), 2)
415 total_gb = round(usage.total / (1024 ** 3), 2)
416 return free_gb, total_gb
417 except Exception:
418 return 0.0, 0.0
421def _detect_read_only_fs() -> bool:
422 """Detect if the filesystem is read-only (ROM, SD card in read mode).
424 Uses user-writable Nunba data dir (~/Documents/Nunba/data/) first,
425 then system temp dir as fallback. Never writes to the app install dir
426 (e.g. C:\\Program Files) which can hang on Windows due to Defender/UAC.
427 """
428 try:
429 import tempfile
430 # Prefer the Nunba data directory (always user-writable)
431 try:
432 from core.platform_paths import get_db_dir
433 user_data = get_db_dir()
434 except ImportError:
435 user_data = os.path.join(os.path.expanduser('~'), 'Documents', 'Nunba', 'data')
436 if os.path.isdir(user_data):
437 test_dir = user_data
438 else:
439 # Fall back to system temp dir (guaranteed writable)
440 test_dir = tempfile.gettempdir()
441 fd, path = tempfile.mkstemp(dir=test_dir, prefix='.hevolve_ro_test_')
442 os.close(fd)
443 os.unlink(path)
444 return False
445 except (OSError, IOError):
446 return True
447 except Exception:
448 return False # If we can't determine, assume writable
451def _detect_gpio() -> bool:
452 """Detect GPIO availability (Raspberry Pi, embedded Linux boards)."""
453 # Check for gpiod (modern Linux GPIO)
454 try:
455 import importlib
456 importlib.import_module('gpiod')
457 return True
458 except ImportError:
459 pass
460 # Check for RPi.GPIO (Raspberry Pi specific)
461 try:
462 import importlib
463 importlib.import_module('RPi.GPIO')
464 return True
465 except ImportError:
466 pass
467 # Check for sysfs GPIO (Linux)
468 if os.path.isdir('/sys/class/gpio'):
469 return True
470 return False
473def _detect_serial() -> bool:
474 """Detect serial port availability (USB-to-serial, UART)."""
475 try:
476 from serial.tools import list_ports
477 ports = list(list_ports.comports())
478 return len(ports) > 0
479 except ImportError:
480 pass
481 # Fallback: check for common Linux serial devices
482 for dev in ['/dev/ttyUSB0', '/dev/ttyACM0', '/dev/ttyS0', '/dev/ttyAMA0']:
483 if os.path.exists(dev):
484 return True
485 return False
488def _detect_camera_hw() -> bool:
489 """Detect camera hardware (USB webcam, CSI camera, V4L2)."""
490 # Check for V4L2 video devices (Linux)
491 if os.path.exists('/dev/video0'):
492 return True
493 # Check for Raspberry Pi camera via vcgencmd. Use run_bounded so a
494 # firmware-stalled vcgencmd can't orphan _readerthread daemons.
495 try:
496 from core.subprocess_safe import run_bounded
497 result = run_bounded(['vcgencmd', 'get_camera'], timeout=3)
498 if result.returncode == 0 and 'detected=1' in result.stdout:
499 return True
500 except (FileNotFoundError, OSError):
501 pass
502 return False
505def _detect_imu() -> bool:
506 """Detect IMU hardware (I2C accelerometer/gyroscope)."""
507 # Check for I2C bus (common on embedded Linux)
508 for bus in range(4):
509 if os.path.exists(f'/dev/i2c-{bus}'):
510 return True
511 # Check for common IMU sysfs entries
512 for path in ['/sys/bus/iio/devices/iio:device0',
513 '/sys/class/misc/accel', '/sys/class/misc/gyro']:
514 if os.path.exists(path):
515 return True
516 return False
519def _detect_gps() -> bool:
520 """Detect GPS hardware (serial GPS, gpsd)."""
521 # Check for gpsd socket
522 try:
523 s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
524 s.settimeout(0.5)
525 s.connect(('127.0.0.1', 2947))
526 s.close()
527 return True
528 except (OSError, ConnectionRefusedError):
529 pass
530 # Check for common GPS serial devices
531 for dev in ['/dev/ttyGPS0', '/dev/ttyGPS', '/dev/serial/by-id/*GPS*']:
532 if os.path.exists(dev):
533 return True
534 return False
537def _detect_lidar() -> bool:
538 """Detect LiDAR hardware (USB LiDAR, ROS topics)."""
539 # Check for common USB LiDAR devices
540 for dev in ['/dev/ttyUSB0', '/dev/rplidar']:
541 if os.path.exists(dev):
542 # Could be LiDAR or other serial device - best effort
543 pass
544 # Check for ROS LiDAR topics (if rclpy available)
545 try:
546 ros_topics_env = os.environ.get('HEVOLVE_ROS_TOPICS', '')
547 if 'scan' in ros_topics_env.lower() or 'lidar' in ros_topics_env.lower():
548 return True
549 except Exception:
550 pass
551 return False
554def check_network_connectivity(timeout: float = 5.0) -> bool:
555 """Quick TCP connectivity check."""
556 host = os.environ.get('HEVOLVE_CONNECTIVITY_HOST', '8.8.8.8')
557 port = int(os.environ.get('HEVOLVE_CONNECTIVITY_PORT', '443'))
558 try:
559 sock = socket.create_connection((host, port), timeout=timeout)
560 sock.close()
561 return True
562 except (socket.timeout, socket.error, OSError):
563 return False
566# ═══════════════════════════════════════════════════════════════
567# Tier classification
568# ═══════════════════════════════════════════════════════════════
570def classify_tier(hw: HardwareProfile) -> NodeTierLevel:
571 """Determine the highest tier this hardware qualifies for.
573 Iterates from COMPUTE_HOST down to OBSERVER. If none match, returns EMBEDDED.
574 EMBEDDED is never excluded - every node contributes. A Raspberry Pi Zero
575 running gossip + sensor bridge matters.
576 """
577 # Check for forced tier override
578 force_tier = os.environ.get(FORCE_TIER_ENV, '').lower()
579 if force_tier:
580 for tier in NodeTierLevel:
581 if tier.value == force_tier:
582 logger.info(f"Tier forced to {tier.value} via {FORCE_TIER_ENV}")
583 return tier
585 for req in TIER_REQUIREMENTS:
586 if (hw.cpu_cores >= req.min_cpu_cores and
587 hw.ram_gb >= req.min_ram_gb and
588 hw.disk_free_gb >= req.min_disk_gb and
589 hw.gpu_vram_gb >= req.min_gpu_vram_gb):
590 return req.tier
592 # Below all thresholds - embedded mode. Sensors, GPIO, gossip relay.
593 # Still counts. Still matters. Every drop.
594 return NodeTierLevel.EMBEDDED
597# ═══════════════════════════════════════════════════════════════
598# Feature resolution
599# ═══════════════════════════════════════════════════════════════
601def resolve_features(
602 tier: NodeTierLevel,
603 hw: HardwareProfile,
604) -> Tuple[List[str], Dict[str, str]]:
605 """Determine which features are enabled/disabled for this tier.
607 Returns (enabled_features, disabled_features_with_reasons).
608 """
609 tier_rank = _TIER_RANK[tier]
610 enabled = []
611 disabled = {}
613 for feature, (min_tier, env_var) in FEATURE_TIER_MAP.items():
614 min_rank = _TIER_RANK[min_tier]
615 if tier_rank >= min_rank:
616 enabled.append(feature)
617 else:
618 disabled[feature] = (
619 f"Requires {min_tier.value} tier "
620 f"(node is {tier.value})"
621 )
623 return enabled, disabled
626def apply_feature_gates(
627 enabled: List[str],
628 disabled: Dict[str, str],
629) -> Dict[str, str]:
630 """Set environment variables for features based on tier.
632 Rule: We NEVER override a user's explicit env var.
633 If the user set HEVOLVE_AGENT_ENGINE_ENABLED=true on a Lite node,
634 we log a warning but respect their choice. We inform, not dictate.
635 """
636 env_set = {}
638 for feature, (_, env_var) in FEATURE_TIER_MAP.items():
639 existing = os.environ.get(env_var)
641 if feature in enabled:
642 # Feature should be enabled
643 if existing is None:
644 os.environ[env_var] = 'true'
645 env_set[env_var] = 'true'
646 # If already set (by user), leave it alone
647 else:
648 # Feature should be disabled for this tier
649 if existing is None:
650 os.environ[env_var] = 'false'
651 env_set[env_var] = 'false'
652 elif existing.lower() == 'true':
653 # User explicitly enabled a feature above their tier
654 logger.warning(
655 f"Feature '{feature}' is enabled via {env_var} but "
656 f"hardware may not support it. "
657 f"Reason: {disabled.get(feature, 'unknown')}. "
658 f"Respecting user override."
659 )
660 # DON'T override - user knows their hardware
662 return env_set
665# ═══════════════════════════════════════════════════════════════
666# Main entry point
667# ═══════════════════════════════════════════════════════════════
669_capabilities: Optional[NodeCapabilities] = None
670_lock = threading.Lock()
673def run_system_check() -> NodeCapabilities:
674 """Full system check: detect → classify → resolve → gate.
676 This is the HART OS boot probe. Called once from init_social().
677 Thread-safe. Result is cached for the lifetime of the process.
678 """
679 global _capabilities
681 with _lock:
682 if _capabilities is not None:
683 return _capabilities
685 t0 = time.time()
687 # Step 1: Detect hardware
688 hw = detect_hardware()
690 # Step 2: Classify contribution tier
691 tier = classify_tier(hw)
693 # Step 3: Resolve features
694 enabled, disabled = resolve_features(tier, hw)
696 # Step 4: Apply feature gates (set env vars)
697 env_set = apply_feature_gates(enabled, disabled)
699 caps = NodeCapabilities(
700 tier=tier,
701 hardware=hw,
702 enabled_features=enabled,
703 disabled_features=disabled,
704 env_vars_set=env_set,
705 detected_at=time.time(),
706 )
708 elapsed = round(time.time() - t0, 2)
709 logger.info(
710 f"HART OS equilibrium: tier={tier.value}, "
711 f"cpu={hw.cpu_cores}, ram={hw.ram_gb}GB, "
712 f"disk={hw.disk_free_gb}GB, gpu_vram={hw.gpu_vram_gb}GB, "
713 f"enabled={len(enabled)}, disabled={len(disabled)}, "
714 f"detection={elapsed}s"
715 )
717 _capabilities = caps
718 return caps
721def get_capabilities() -> Optional[NodeCapabilities]:
722 """Get cached capabilities, or None if run_system_check() hasn't been called."""
723 return _capabilities
726def get_tier() -> NodeTierLevel:
727 """Get current tier. Returns EMBEDDED if not yet detected."""
728 if _capabilities:
729 return _capabilities.tier
730 return NodeTierLevel.EMBEDDED
733def get_tier_name() -> str:
734 """Get current tier as string. Convenience for gossip/API."""
735 return get_tier().value
738def reset_for_testing():
739 """Reset cached state. For tests only."""
740 global _capabilities
741 with _lock:
742 _capabilities = None