commit dc1d276df66f69a0fe608f021660235c9f74e0d6 Author: Christian Baer Date: Thu May 22 07:56:25 2025 +0200 Initial commit: System2MQTT implementation diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..937446a --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environment +.venv/ +venv/ +env/ +ENV/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Project specific +config.yaml \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..779cf4d --- /dev/null +++ b/README.md @@ -0,0 +1,126 @@ +# system2mqtt + +Ein System zur Überwachung von Hosts durch Sammeln von Metriken und Senden via MQTT an Home Assistant. + +## Features + +- Modulare Struktur für Metrik-Sammler +- Einfache Erweiterbarkeit durch neue Sammler +- Automatische Erkennung in Home Assistant +- Verschlüsselte MQTT-Kommunikation +- Detaillierte Geräteinformationen in Home Assistant + +## Installation + +1. Repository klonen: +```bash +git clone https://github.com/yourusername/system2mqtt.git +cd system2mqtt +``` + +2. Python-Abhängigkeiten installieren: +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` + +3. Konfiguration anpassen: +```yaml +mqtt: + host: "mqtt.example.com" # MQTT Broker Adresse + port: 1883 # MQTT Port + username: "your_username" + password: "your_password" + client_id: "system2mqtt" + discovery_prefix: "homeassistant" # Home Assistant Discovery Prefix +``` + +## Verwendung + +Das System wird über das `run.sh` Skript gesteuert: + +```bash +# Starten des Systems +./run.sh start + +# Stoppen des Systems +./run.sh stop + +# MQTT Topics aufräumen +./run.sh cleanup +``` + +## Sammler + +### System Metrics + +Sammelt grundlegende Systemmetriken: +- Last Boot Zeit +- Load Average (1, 5, 15 Minuten) +- Speichernutzung (Gesamt, Verfügbar, Verwendet) +- Swap-Nutzung (Gesamt, Verfügbar, Verwendet) +- CPU-Auslastung +- Speicherauslastung +- Swap-Auslastung + +### CPU Temperature + +Sammelt CPU-Temperaturdaten: +- Unterstützt Linux und FreeBSD +- Automatische Erkennung des Betriebssystems +- Korrekte Einheit (°C) und Device Class (temperature) + +## Datenformat + +Das Datenaustauschformat ist versioniert und folgt den Home Assistant Spezifikationen. Jeder Collector gibt ein JSON-Objekt zurück mit folgender Struktur: + +```json +{ + "entities": [ + { + "sensor_id": "unique_sensor_id", + "name": "Sensor Name", + "value": "Sensor Value", + "state_class": "measurement|total|total_increasing", + "unit_of_measurement": "Unit", + "device_class": "temperature|humidity|pressure|...", + "icon": "mdi:icon-name", + "entity_category": "diagnostic|config|system", + "attributes": { + "friendly_name": "Friendly Name", + "source": "Data Source", + "additional_info": "Additional Information" + } + } + ] +} +``` + +### Felder + +- `sensor_id`: Eindeutige ID für den Sensor (wird für MQTT-Topics verwendet) +- `name`: Anzeigename des Sensors +- `value`: Aktueller Wert des Sensors +- `state_class`: Art der Messung (measurement, total, total_increasing) +- `unit_of_measurement`: Einheit der Messung +- `device_class`: Art des Sensors (temperature, humidity, pressure, etc.) +- `icon`: Material Design Icon Name (mdi:...) +- `entity_category`: Kategorie des Sensors (diagnostic, config, system) +- `attributes`: Zusätzliche Informationen als Key-Value-Paare + +### Versionierung + +Das Format ist versioniert, um zukünftige Erweiterungen zu ermöglichen. Die aktuelle Version ist 1.0. + +## Home Assistant Integration + +Das System nutzt die MQTT Discovery-Funktion von Home Assistant. Die Sensoren werden automatisch erkannt und erscheinen in Home Assistant mit: +- Korrektem Namen und Icon +- Aktuellen Werten +- Historischen Daten +- Detaillierten Geräteinformationen + +## Lizenz + +MIT License \ No newline at end of file diff --git a/cleanup_mqtt.py b/cleanup_mqtt.py new file mode 100755 index 0000000..115630b --- /dev/null +++ b/cleanup_mqtt.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 + +import os +import sys +import yaml +import paho.mqtt.client as mqtt +import time + +def load_config(): + """Load configuration from YAML file.""" + try: + with open('config.yaml', 'r') as f: + return yaml.safe_load(f) + except Exception as e: + print(f"Error loading config: {e}") + sys.exit(1) + +def on_connect(client, userdata, flags, rc): + """Callback for when the client connects to the MQTT broker.""" + if rc == 0: + print("Connected to MQTT broker") + else: + print(f"Failed to connect, return code {rc}") + sys.exit(1) + +def on_message(client, userdata, message): + """Callback for when a message is received.""" + userdata.append(message.topic) + +def get_topics(client, timeout=1): + """Get all system2mqtt topics from the MQTT broker.""" + topics = [] + client.user_data_set(topics) + client.on_message = on_message + + # Subscribe to all topics + client.subscribe('#') + client.loop_start() + + # Wait for messages + time.sleep(timeout) + + client.loop_stop() + client.unsubscribe('#') + + # Filter for system2mqtt topics + return [topic for topic in topics if 'system2mqtt' in topic] + +def cleanup_topics(config): + """Clean up MQTT topics.""" + # Create MQTT client + client = mqtt.Client() + client.username_pw_set(config['mqtt']['username'], config['mqtt']['password']) + client.on_connect = on_connect + + # Connect to MQTT broker + try: + print(f"Connecting to MQTT broker at {config['mqtt']['host']}:{config['mqtt']['port']}...") + client.connect(config['mqtt']['host'], config['mqtt']['port'], 60) + except Exception as e: + print(f"Failed to connect to MQTT broker: {e}") + sys.exit(1) + + # Get all system2mqtt topics + print("Finding system2mqtt topics...") + topics = get_topics(client) + + if not topics: + print("No system2mqtt topics found") + return + + # Delete each topic + for topic in topics: + print(f"Deleting topic: {topic}") + client.publish(topic, "", retain=True) + time.sleep(0.1) # Small delay between deletions + + # Wait for messages to be published + time.sleep(1) + + # Stop the MQTT client loop and disconnect + client.disconnect() + + print("Cleanup completed") + +if __name__ == "__main__": + config = load_config() + cleanup_topics(config) \ No newline at end of file diff --git a/collectors/cpu_temperature.py b/collectors/cpu_temperature.py new file mode 100644 index 0000000..0e1ea0e --- /dev/null +++ b/collectors/cpu_temperature.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 + +import os +import platform +import subprocess +import glob +from typing import Dict, Any, Optional, List, Tuple +import sys + +def get_temperature_linux_coretemp() -> List[Tuple[float, str]]: + """Get CPU temperatures using coretemp module.""" + temps = [] + try: + for hwmon_dir in glob.glob('/sys/class/hwmon/hwmon*'): + try: + with open(os.path.join(hwmon_dir, 'name'), 'r') as f: + if f.read().strip() == 'coretemp': + # Found coretemp, get all temperatures + for temp_file in glob.glob(os.path.join(hwmon_dir, 'temp*_input')): + try: + with open(temp_file, 'r') as tf: + temp = float(tf.read().strip()) / 1000.0 + # Get label if available + label = "Package" + label_file = temp_file.replace('_input', '_label') + if os.path.exists(label_file): + with open(label_file, 'r') as lf: + label = lf.read().strip() + temps.append((temp, label)) + except (FileNotFoundError, ValueError): + continue + except (FileNotFoundError, ValueError): + continue + except Exception: + pass + return temps + +def get_temperature_linux_thermal() -> List[Tuple[float, str]]: + """Get CPU temperatures using thermal zones.""" + temps = [] + try: + for thermal_dir in glob.glob('/sys/class/thermal/thermal_zone*'): + try: + with open(os.path.join(thermal_dir, 'type'), 'r') as f: + zone_type = f.read().strip() + if 'cpu' in zone_type.lower(): + with open(os.path.join(thermal_dir, 'temp'), 'r') as tf: + temp = float(tf.read().strip()) / 1000.0 + temps.append((temp, zone_type)) + except (FileNotFoundError, ValueError): + continue + except Exception: + pass + return temps + +def get_temperature_freebsd() -> List[Tuple[float, str]]: + """Get CPU temperatures on FreeBSD systems.""" + temps = [] + try: + # Get number of CPUs + cpu_count = int(subprocess.check_output(['sysctl', '-n', 'hw.ncpu']).decode().strip()) + + # Get temperature for each CPU + for cpu in range(cpu_count): + try: + temp = subprocess.check_output(['sysctl', '-n', f'dev.cpu.{cpu}.temperature']).decode().strip() + temp_value = float(temp) + temps.append((temp_value, f'CPU {cpu}')) + except (subprocess.SubprocessError, ValueError): + continue + except (subprocess.SubprocessError, ValueError): + pass + return temps + +def collect_metrics() -> Dict[str, Any]: + """Collect CPU temperature metrics.""" + metrics = { + "entities": [] + } + + temps = [] + + # Get CPU temperatures based on OS + if sys.platform.startswith('linux'): + # Try coretemp first (most reliable) + temps.extend(get_temperature_linux_coretemp()) + + # If no coretemp found, try thermal zones + if not temps: + temps.extend(get_temperature_linux_thermal()) + + elif sys.platform.startswith('freebsd'): + temps.extend(get_temperature_freebsd()) + + # Add temperature sensors + if temps: + # Only keep package temperatures + package_temps = [(t, l) for t, l in temps if 'Package' in l] + + # Add package temperature + for temp, label in package_temps: + metrics['entities'].append({ + 'sensor_id': 'cpu_temperature', + 'name': 'CPU Temperature', + 'value': str(round(temp, 1)), + 'state_class': 'measurement', + 'unit_of_measurement': '°C', + 'device_class': 'temperature', + 'icon': 'mdi:thermometer', + 'attributes': { + 'friendly_name': 'CPU Temperature', + 'source': 'coretemp' + } + }) + + return metrics + +if __name__ == "__main__": + # Example usage + metrics = collect_metrics() + print(metrics) \ No newline at end of file diff --git a/collectors/system_metrics.py b/collectors/system_metrics.py new file mode 100644 index 0000000..663fe33 --- /dev/null +++ b/collectors/system_metrics.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 + +import psutil +import time +from datetime import datetime +from typing import Dict, Any + +def collect_metrics() -> Dict[str, Any]: + """Collect system metrics and return them in the required format.""" + # Get system metrics + boot_time = datetime.fromtimestamp(psutil.boot_time()) + load_avg = psutil.getloadavg() + memory = psutil.virtual_memory() + swap = psutil.swap_memory() + cpu_percent = psutil.cpu_percent(interval=1) + + # Convert bytes to GB + def to_gb(bytes_value: int) -> float: + return round(bytes_value / (1024**3), 2) + + return { + "version": "1.0", + "entities": [ + { + "name": "Last Boot", + "sensor_id": "last_boot", + "state_class": "total", + "device_class": "timestamp", + "unit_of_measurement": "", + "value": boot_time.astimezone().isoformat(), + "icon": "mdi:clock-time-four", + "attributes": { + "friendly_name": "Last Boot Time" + } + }, + { + "name": "Load Average (15m)", + "sensor_id": "load_15m", + "state_class": "measurement", + "unit_of_measurement": "", + "device_class": "power_factor", + "value": str(round(load_avg[2], 1)), + "icon": "mdi:gauge", + "attributes": { + "friendly_name": "System Load (15m)" + } + }, + { + "name": "Load Average (5m)", + "sensor_id": "load_5m", + "state_class": "measurement", + "unit_of_measurement": "", + "device_class": "power_factor", + "value": str(round(load_avg[1], 1)), + "icon": "mdi:gauge", + "attributes": { + "friendly_name": "System Load (5m)" + } + }, + { + "name": "Load Average (1m)", + "sensor_id": "load_1m", + "state_class": "measurement", + "unit_of_measurement": "", + "device_class": "power_factor", + "value": str(round(load_avg[0], 1)), + "icon": "mdi:gauge", + "attributes": { + "friendly_name": "System Load (1m)" + } + }, + { + "name": "Memory Free", + "sensor_id": "memory_free", + "state_class": "measurement", + "unit_of_measurement": "GB", + "device_class": "data_size", + "value": str(to_gb(memory.available)), + "icon": "mdi:memory", + "attributes": { + "friendly_name": "Available Memory" + } + }, + { + "name": "Memory Used", + "sensor_id": "memory_used", + "state_class": "measurement", + "unit_of_measurement": "GB", + "device_class": "data_size", + "value": str(to_gb(memory.used)), + "icon": "mdi:memory", + "attributes": { + "friendly_name": "Used Memory" + } + }, + { + "name": "Memory Usage", + "sensor_id": "memory_usage", + "state_class": "measurement", + "unit_of_measurement": "%", + "device_class": "power_factor", + "value": str(memory.percent), + "icon": "mdi:memory", + "attributes": { + "friendly_name": "Memory Usage" + } + }, + { + "name": "CPU Usage", + "sensor_id": "cpu_usage", + "state_class": "measurement", + "unit_of_measurement": "%", + "device_class": "power_factor", + "value": str(cpu_percent), + "icon": "mdi:cpu-64-bit", + "attributes": { + "friendly_name": "CPU Usage" + } + }, + { + "name": "Swap Free", + "sensor_id": "swap_free", + "state_class": "measurement", + "unit_of_measurement": "GB", + "device_class": "data_size", + "value": str(to_gb(swap.free)), + "icon": "mdi:harddisk", + "attributes": { + "friendly_name": "Free Swap" + } + }, + { + "name": "Swap Used", + "sensor_id": "swap_used", + "state_class": "measurement", + "unit_of_measurement": "GB", + "device_class": "data_size", + "value": str(to_gb(swap.used)), + "icon": "mdi:harddisk", + "attributes": { + "friendly_name": "Used Swap" + } + }, + { + "name": "Swap Usage", + "sensor_id": "swap_usage", + "state_class": "measurement", + "unit_of_measurement": "%", + "device_class": "power_factor", + "value": str(swap.percent), + "icon": "mdi:harddisk", + "attributes": { + "friendly_name": "Swap Usage" + } + } + ] + } + +if __name__ == "__main__": + # Example usage + metrics = collect_metrics() + print(metrics) \ No newline at end of file diff --git a/config.yaml.example b/config.yaml.example new file mode 100644 index 0000000..0bd06f5 --- /dev/null +++ b/config.yaml.example @@ -0,0 +1,8 @@ +mqtt: + host: "mqtt.example.com" + port: 1883 + username: "system2mqtt" + password: "your_secure_password" + client_id: "system2mqtt_{hostname}" + discovery_prefix: "homeassistant" + state_prefix: "system2mqtt" \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..50c7215 --- /dev/null +++ b/main.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 + +import os +import sys +import json +import time +import socket +import platform +import importlib +import paho.mqtt.client as mqtt +from typing import Dict, Any, List +import yaml + +# Load configuration +with open('config.yaml', 'r') as f: + config = yaml.safe_load(f) + +# MQTT Configuration +MQTT_HOST = config['mqtt']['host'] +MQTT_PORT = config['mqtt']['port'] +MQTT_USERNAME = config['mqtt']['username'] +MQTT_PASSWORD = config['mqtt']['password'] +MQTT_CLIENT_ID = config['mqtt']['client_id'] +MQTT_DISCOVERY_PREFIX = config['mqtt']['discovery_prefix'] + +# Get hostname +HOSTNAME = socket.gethostname() + +# Get OS information +OS_INFO = { + 'system': platform.system(), + 'release': platform.release(), + 'version': platform.version(), + 'machine': platform.machine(), + 'processor': platform.processor() +} + +def get_collectors() -> List[str]: + """Get list of available collector scripts.""" + collectors = [] + for file in os.listdir('collectors'): + if file.endswith('.py') and not file.startswith('__'): + collectors.append(file[:-3]) + return collectors + +def load_collector(collector_name: str) -> Any: + """Load a collector module.""" + try: + return importlib.import_module(f'collectors.{collector_name}') + except ImportError as e: + print(f"Error loading collector {collector_name}: {e}") + return None + +def get_device_info() -> Dict[str, Any]: + """Get device information for Home Assistant.""" + return { + "identifiers": [f"system2mqtt_{HOSTNAME}"], + "name": f"System Metrics - {HOSTNAME}", + "model": OS_INFO['system'], + "manufacturer": "system2mqtt", + "sw_version": OS_INFO['version'], + "configuration_url": f"http://{HOSTNAME}", + "hw_version": OS_INFO['machine'] + } + +def get_device_attributes() -> Dict[str, Any]: + """Get additional device attributes.""" + return { + "operating_system": OS_INFO['system'], + "os_release": OS_INFO['release'], + "os_version": OS_INFO['version'], + "architecture": OS_INFO['machine'], + "processor": OS_INFO['processor'] + } + +def on_connect(client: mqtt.Client, userdata: Any, flags: Dict[str, Any], rc: int) -> None: + """Callback for when the client connects to the MQTT broker.""" + print(f"Connected with result code {rc}") + # Subscribe to any topics if needed + # client.subscribe("$SYS/#") + +def on_disconnect(client: mqtt.Client, userdata: Any, rc: int) -> None: + """Callback for when the client disconnects from the MQTT broker.""" + print(f"Disconnected with result code {rc}") + if rc != 0: + print("Unexpected disconnection. Attempting to reconnect...") + +def main(): + """Main function.""" + # Create MQTT client + client = mqtt.Client(client_id=MQTT_CLIENT_ID) + client.username_pw_set(MQTT_USERNAME, MQTT_PASSWORD) + client.on_connect = on_connect + client.on_disconnect = on_disconnect + + # Connect to MQTT broker + try: + client.connect(MQTT_HOST, MQTT_PORT, 60) + except Exception as e: + print(f"Failed to connect to MQTT broker: {e}") + sys.exit(1) + + # Start the MQTT client loop + client.loop_start() + + # Get list of collectors + collectors = get_collectors() + print(f"Found collectors: {collectors}") + + # Main loop + while True: + try: + # Load and run each collector + for collector_name in collectors: + collector = load_collector(collector_name) + if collector: + try: + # Get metrics from collector + metrics = collector.collect_metrics() + + # Add device info to each entity + device_info = get_device_info() + device_attributes = get_device_attributes() + + for entity in metrics.get('entities', []): + # Create discovery topic + discovery_topic = f"{MQTT_DISCOVERY_PREFIX}/sensor/system2mqtt_{HOSTNAME}_{entity['sensor_id']}/config" + + # Create discovery payload + discovery_payload = { + "name": entity['name'], + "unique_id": f"system2mqtt_{HOSTNAME}_{entity['sensor_id']}", + "state_topic": f"system2mqtt/{HOSTNAME}/{entity['sensor_id']}/state", + "state_class": entity['state_class'], + "unit_of_measurement": entity['unit_of_measurement'], + "device_class": entity['device_class'], + "icon": entity.get('icon'), + "device": device_info, + "availability": { + "topic": f"system2mqtt/{HOSTNAME}/status", + "payload_available": "online", + "payload_not_available": "offline" + }, + "json_attributes_topic": f"system2mqtt/{HOSTNAME}/{entity['sensor_id']}/attributes" + } + + # Publish discovery message + client.publish(discovery_topic, json.dumps(discovery_payload), retain=True) + + # Publish state + state_topic = f"system2mqtt/{HOSTNAME}/{entity['sensor_id']}/state" + client.publish(state_topic, entity['value'], retain=True) + + # Publish attributes + attributes = entity.get('attributes', {}) + attributes.update(device_attributes) # Add device attributes + attributes_topic = f"system2mqtt/{HOSTNAME}/{entity['sensor_id']}/attributes" + client.publish(attributes_topic, json.dumps(attributes), retain=True) + + except Exception as e: + print(f"Error collecting metrics from {collector_name}: {e}") + continue + + # Publish availability status + client.publish(f"system2mqtt/{HOSTNAME}/status", "online", retain=True) + + # Wait before next collection + time.sleep(60) # Collect metrics every minute + + except KeyboardInterrupt: + print("Stopping...") + # Publish offline status before stopping + client.publish(f"system2mqtt/{HOSTNAME}/status", "offline", retain=True) + break + except Exception as e: + print(f"Error in main loop: {e}") + time.sleep(60) # Wait before retrying + + # Stop the MQTT client loop and disconnect + client.loop_stop() + client.disconnect() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2a7f93f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +paho-mqtt==1.6.1 +psutil==5.9.5 +PyYAML==6.0.1 \ No newline at end of file diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..9973240 --- /dev/null +++ b/run.sh @@ -0,0 +1,73 @@ +#!/bin/bash + +# Virtual environment directory +VENV_DIR=".venv" + +# Function to show usage +show_usage() { + echo "Usage: $0 [start|stop|cleanup]" + echo + echo "Commands:" + echo " start - Start the system2mqtt service" + echo " stop - Stop the system2mqtt service" + echo " cleanup - Clean up all system2mqtt MQTT topics" + echo + exit 1 +} + +# Function to setup virtual environment +setup_venv() { + # Create virtual environment if it doesn't exist + if [ ! -d "$VENV_DIR" ]; then + echo "Creating virtual environment..." + python3 -m venv "$VENV_DIR" + fi + + # Activate virtual environment + source "$VENV_DIR/bin/activate" + + # Install/update dependencies + echo "Installing/updating dependencies..." + pip install -r requirements.txt +} + +# Function to start the service +start_service() { + echo "Starting system2mqtt..." + setup_venv + python3 main.py +} + +# Function to stop the service +stop_service() { + echo "Stopping system2mqtt..." + pkill -f "python3 main.py" +} + +# Function to cleanup MQTT topics +cleanup_topics() { + echo "Running MQTT cleanup..." + setup_venv + python3 cleanup_mqtt.py +} + +# Check if a command was provided +if [ $# -eq 0 ]; then + show_usage +fi + +# Process command +case "$1" in + start) + start_service + ;; + stop) + stop_service + ;; + cleanup) + cleanup_topics + ;; + *) + show_usage + ;; +esac \ No newline at end of file diff --git a/system2mqtt.py b/system2mqtt.py new file mode 100644 index 0000000..97bd31e --- /dev/null +++ b/system2mqtt.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 + +import json +import os +import socket +import sys +import time +import yaml +import platform +import paho.mqtt.client as mqtt +from typing import Dict, Any +import importlib.util +import glob + +class System2MQTT: + def __init__(self, config_path: str = "config.yaml"): + self.config = self._load_config(config_path) + self.hostname = socket.gethostname() + self.client = self._setup_mqtt_client() + self.connected = False + self.device_info = self._get_device_info() + self.collectors = self._load_collectors() + + def _load_config(self, config_path: str) -> Dict[str, Any]: + """Load configuration from YAML file.""" + try: + with open(config_path, 'r') as f: + return yaml.safe_load(f) + except Exception as e: + print(f"Error loading config: {e}") + sys.exit(1) + + def _setup_mqtt_client(self) -> mqtt.Client: + """Setup MQTT client with configuration.""" + client = mqtt.Client( + client_id=self.config['mqtt']['client_id'].format(hostname=self.hostname) + ) + client.username_pw_set( + self.config['mqtt']['username'], + self.config['mqtt']['password'] + ) + client.on_connect = self._on_connect + client.on_disconnect = self._on_disconnect + return client + + def _on_connect(self, client, userdata, flags, rc): + """Callback for when the client connects to the broker.""" + if rc == 0: + print("Connected to MQTT broker") + self.connected = True + else: + print(f"Failed to connect to MQTT broker with code: {rc}") + self.connected = False + + def _on_disconnect(self, client, userdata, rc): + """Callback for when the client disconnects from the broker.""" + print(f"Disconnected from MQTT broker with code: {rc}") + self.connected = False + + def _get_device_info(self) -> Dict[str, Any]: + """Get device information for Home Assistant.""" + return { + "identifiers": [f"system2mqtt_{self.hostname}"], + "name": f"System {self.hostname}", + "model": platform.machine(), + "manufacturer": platform.system() + } + + def _get_unique_id(self, sensor_id: str) -> str: + """Generate unique_id from sensor_id.""" + return f"system2mqtt_{self.hostname}_{sensor_id}" + + def _get_state_topic(self, sensor_id: str) -> str: + """Generate state topic from sensor_id.""" + return f"system2mqtt/{self.hostname}/{sensor_id}/state" + + def _load_collectors(self) -> list: + """Load all collector modules from the collectors directory.""" + collectors = [] + collector_dir = os.path.join(os.path.dirname(__file__), 'collectors') + + # Find all Python files in the collectors directory + collector_files = glob.glob(os.path.join(collector_dir, '*.py')) + + for collector_file in collector_files: + if collector_file.endswith('__init__.py'): + continue + + module_name = os.path.splitext(os.path.basename(collector_file))[0] + spec = importlib.util.spec_from_file_location(module_name, collector_file) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + if hasattr(module, 'collect_metrics'): + collectors.append(module) + print(f"Loaded collector: {module_name}") + + return collectors + + def process_collector_data(self, data: Dict[str, Any]): + """Process data from collectors and publish to MQTT.""" + if not self.connected: + print("Not connected to MQTT broker") + return + + # Publish discovery messages for each entity + for entity in data['entities']: + sensor_id = entity['sensor_id'] + unique_id = self._get_unique_id(sensor_id) + state_topic = self._get_state_topic(sensor_id) + discovery_topic = f"{self.config['mqtt']['discovery_prefix']}/sensor/{unique_id}/config" + + # Prepare discovery message + discovery_msg = { + "name": entity['name'], + "unique_id": unique_id, + "state_topic": state_topic, + "state_class": entity['state_class'], + "unit_of_measurement": entity['unit_of_measurement'], + "device_class": entity['device_class'], + "device": self.device_info + } + + # Publish discovery message + self.client.publish( + discovery_topic, + json.dumps(discovery_msg), + retain=True + ) + + # Publish state + self.client.publish( + state_topic, + entity['value'], + retain=True + ) + + # Publish attributes if present + if 'attributes' in entity: + attributes_topic = f"{state_topic}/attributes" + self.client.publish( + attributes_topic, + json.dumps(entity['attributes']), + retain=True + ) + + def collect_and_publish(self): + """Collect metrics from all collectors and publish them.""" + for collector in self.collectors: + try: + data = collector.collect_metrics() + self.process_collector_data(data) + except Exception as e: + print(f"Error collecting metrics from {collector.__name__}: {e}") + + def connect(self): + """Connect to MQTT broker.""" + try: + self.client.connect( + self.config['mqtt']['broker'], + self.config['mqtt']['port'] + ) + self.client.loop_start() + except Exception as e: + print(f"Error connecting to MQTT broker: {e}") + sys.exit(1) + + def disconnect(self): + """Disconnect from MQTT broker.""" + self.client.loop_stop() + self.client.disconnect() + +def main(): + """Main function.""" + system2mqtt = System2MQTT() + system2mqtt.connect() + + try: + # Initial collection + system2mqtt.collect_and_publish() + + # Keep collecting metrics every 60 seconds + while True: + time.sleep(60) + system2mqtt.collect_and_publish() + except KeyboardInterrupt: + print("\nShutting down...") + finally: + system2mqtt.disconnect() + +if __name__ == "__main__": + main() \ No newline at end of file