29 Commits

Author SHA1 Message Date
f44df8b1fe Fix memory_used OID parsing for Mikrotik devices
Handle OctetString values returned as bytes by converting them to int
using little-endian byte order (4 bytes) for Mikrotik devices.
2026-06-09 22:48:27 +02:00
8e6d193d4c feat: Ajoute les services systemd pour snmp2mqtt 2026-06-09 16:52:58 +02:00
c350ab2203 feat: Ajoute snmp-discover.py pour la découverte semi-guidée des OIDs 2026-06-09 16:45:08 +02:00
4516450a2e Update AGENTS.md 2026-05-24 11:27:38 +02:00
c0a8929084 Fix: Gérer les OID invalides avec un avertissement
Plutôt que de générer une erreur lorsqu'un OID SNMP renvoie une valeur vide ou non-convertible, le système émet désormais un simple avertissement.

Cela évite de polluer les logs d'erreurs pour des cas non-bloquants, comme un port débranché qui renvoie une chaîne vide au lieu d'un entier.
2026-02-15 21:12:15 +01:00
6c7248f08d Mise à jour de la documentation (humaine + IA) 2025-11-06 17:08:37 +01:00
7888999ec3 feat: Ajoute la transformation de valeur par opération mathématique
Cette modification introduit la possibilité d'appliquer une opération mathématique simple (addition, soustraction, multiplication, division) sur les valeurs SNMP récupérées.

L'utilisateur peut désormais définir une clé 'operation' dans la configuration d'un OID (par exemple, 'value / 1000') pour normaliser les données avant leur envoi à MQTT. La documentation dans `config.yaml` a été mise à jour avec un exemple.
2025-11-06 17:05:20 +01:00
ac476df7ce fix: les capteurs 'data_size' ont l'attribut 'total_increasing' 2025-09-09 22:33:37 +02:00
f48a9d1417 Aide au codage 2025-09-09 22:32:57 +02:00
b7331faba0 logging INFO par défaut 2025-08-24 16:57:21 +02:00
3875f4501f Config example 2025-08-24 16:47:23 +02:00
7fec5c5049 DEBUG c'est quand même très verbeux -> passage à INFO:x 2025-08-24 16:39:08 +02:00
2309ef1deb Mise à jour avec la nouvelle API (V2) de Paho MQTT 2025-08-24 16:37:25 +02:00
75f8c6a637 Remove WARP.md from repo 2025-08-24 16:27:18 +02:00
0d54632c52 Mise à jour en lien avec les changements dans PySNMP 2025-08-24 16:25:35 +02:00
5b670c4708 Fix UdpTransportTarget 2025-08-24 15:44:00 +02:00
85a14f4fa0 Update PySNMP (Slim is now deprecated) 2025-08-24 15:36:26 +02:00
e4e79a34a9 Merge branch 'ha-mqtt-autodiscovery'
Fusion du README pour l'autodiscovery HA
2025-08-24 15:23:39 +02:00
ffd86281ef Merge branch 'multithreading-for-multiple-devices'
Fusion du README pour le MT
2025-08-24 15:23:10 +02:00
e45c3c1f18 Informations sur l'autodécouverte MQTT Home Assistant 2025-08-24 15:22:51 +02:00
7199432169 Informations sur le multithreading 2025-08-24 15:19:24 +02:00
4e2dec2441 Home Assistant auto-discovery 2025-08-24 15:15:31 +02:00
083cfcce1d Execution multithread 2025-08-24 14:48:51 +02:00
96b0cd9b99 Fichier README 2025-08-24 14:38:42 +02:00
ca4ad8ed06 Ajout d'un fichier de configuraiton séparé 2025-08-24 14:32:06 +02:00
819ff2ed97 Ajout des interfaces LAN et Starlink 2025-08-23 20:21:24 +02:00
0391f91809 Agent WARP 2025-08-23 20:17:34 +02:00
3796f500e7 QOS 2 pour la config 2025-04-10 21:50:47 +02:00
ec1dad8e8c Auto discovery avec Home Assistant 2025-04-10 21:45:35 +02:00
10 changed files with 1741 additions and 30 deletions

1
.gitignore vendored
View File

@@ -172,3 +172,4 @@ cython_debug/
#.idea/
WARP.md

79
AGENTS.md Normal file
View File

@@ -0,0 +1,79 @@
# Fichier d'aide pour Gemini
Ce document fournit un résumé concis du projet `snmp2mqtt` pour aider au développement et à la maintenance assistés par l'IA.
## 1. Objectif du Projet
Le projet `snmp2mqtt` est une passerelle écrite en Python qui a pour but de :
1. **Interroger** des équipements réseau (routeurs, switchs, etc.) via le protocole **SNMP**.
2. **Récupérer** des métriques spécifiques (trafic, statut des ports, etc.) définies par des OIDs.
3. **Publier** ces données sur un broker **MQTT**.
4. **S'intégrer automatiquement** avec **Home Assistant** grâce au mécanisme de "MQTT Discovery", permettant de créer des capteurs sans configuration manuelle côté Home Assistant.
## 2. Architecture et Technologies
- **Langage** : Python 3
- **Dépendances principales** (`requirements.txt`) :
- `pysnmp>=7.0.0` : Pour la communication SNMP asynchrone.
- `paho-mqtt>=2.0.0` : Pour la communication avec le broker MQTT.
- `PyYAML>=6.0.0` : Pour le parsing du fichier de configuration.
- **Configuration** : Un unique fichier `config.yaml` centralise tous les paramètres (MQTT, appareils, OIDs).
- **Exécution** : Le script utilise le multi-threading. Un thread est démarré pour chaque appareil défini dans la configuration, ce qui permet une surveillance parallèle et isolée.
## 3. Structure du Code (`snmp2mqtt.py`)
Le script principal est organisé de la manière suivante :
1. **`main()`** : Point d'entrée. Il parse les arguments (`--config`), charge la configuration et appelle `process_devices()`.
2. **`process_devices(config)`** :
- Orchestre le lancement des threads.
- Crée et démarre une instance de `DeviceMonitorThread` pour chaque appareil.
- Gère l'arrêt propre (`graceful shutdown`) en attendant que tous les threads se terminent.
3. **`DeviceMonitorThread(threading.Thread)`** :
- Classe qui encapsule la logique de surveillance pour un seul appareil.
- `run()` : méthode principale du thread.
- Établit la connexion MQTT.
- Publie la configuration de découverte automatique pour Home Assistant (une seule fois au démarrage).
- Entre dans une boucle infinie qui :
- Appelle `get_snmp()` pour récupérer les données.
- Publie l'état des capteurs et le statut de disponibilité (`online`/`offline`) sur MQTT.
- Attend un intervalle (`sleep_interval`) avant la prochaine interrogation.
4. **`get_snmp(req)`** :
- Fonction `async` qui utilise `pysnmp` pour exécuter les requêtes `GET` SNMP pour tous les OIDs d'un appareil.
- Traite la clé optionnelle `operation` pour appliquer une transformation mathématique.
- Retourne un dictionnaire contenant les valeurs formatées.
5. **Fonctions de configuration et MQTT** :
- `load_config()` : Charge et valide le fichier `config.yaml`.
- `connect_mqtt()` : Initialise le client MQTT.
- `publish()` : Wrapper pour publier les messages MQTT.
- `publish_ha_autodiscovery_config()` : Construit et publie les messages de configuration pour Home Assistant MQTT Discovery.
- `apply_operation(value, operation_str)` : Fonction d'aide qui applique de manière sécurisée une opération mathématique simple à une valeur.
## 4. Flux de Données
```
[Appareil SNMP] <--- (Requête SNMP GET) --- [snmp2mqtt.py / Thread]
|
| (Réponse SNMP)
v
[snmp2mqtt.py / Thread] --- (Publication MQTT) ---> [Broker MQTT]
|
| (MQTT Discovery & State)
v
[Home Assistant]
```
## 5. Comment développer
- **Environnement** :
1. Créer un environnement virtuel : `python3 -m venv .venv`
2. Activer l'environnement : `source .venv/bin/activate`
3. Installer les dépendances : `pip install -r requirements.txt`
- **Configuration** :
- Copier et modifier `config.yaml` pour pointer vers un broker MQTT de test et un appareil SNMP accessible.
- **Lancement** :
- `python snmp2mqtt.py --config config.yaml`
- **Points clés à modifier** :
- Pour ajouter une nouvelle fonctionnalité à un capteur Home Assistant, modifier `create_ha_sensor_config()`.
- Pour changer la logique de récupération SNMP, modifier `get_snmp()`.
- Pour ajouter de nouveaux paramètres de configuration, mettre à jour `load_config()` pour la validation.

481
README.md
View File

@@ -1,3 +1,482 @@
# snmp2mqtt
Script python pour relayer les infos snmp en mqtt
Passerelle SNMP vers MQTT pour l'intégration Home Assistant. Ce script Python surveille les équipements réseau via SNMP et publie les données vers un broker MQTT pour une découverte automatique dans Home Assistant.
## Fonctionnalités
- 🔧 **Configuration YAML flexible** : Tous les paramètres dans un fichier de configuration
- 📡 **Support multi-équipements** : Surveillez plusieurs périphériques réseau
- 🏠 **Intégration Home Assistant** : Découverte automatique des capteurs
-**SNMP asynchrone** : Requêtes SNMP non-bloquantes pour de meilleures performances
- 🔄 **Surveillance en temps réel** : Mise à jour continue des métriques réseau
- 📊 **Métriques réseau** : Trafic entrant/sortant et statut des interfaces
- 🔢 **Transformation de données** : Appliquez des opérations mathématiques simples (division, multiplication...) pour normaliser les valeurs.
## Architecture
### Composants principaux
- **Client SNMP** : Utilise `pysnmp.hlapi.asyncio` (version 7.x) pour la récupération asynchrone des données SNMP avec `get_cmd`, `SnmpEngine` et `UdpTransportTarget`
- **Publisher MQTT** : Utilise `paho.mqtt.client` pour publier les données vers un broker MQTT
- **Intégration Home Assistant** : Génère la configuration de découverte automatique compatible avec Home Assistant MQTT Discovery
- **Traitement des données** : Convertit les valeurs des OID SNMP vers les types appropriés (int, bool) pour les capteurs Home Assistant
## Installation
### Prérequis
- Python 3.7 ou supérieur
- Accès réseau aux équipements SNMP à surveiller
- Broker MQTT accessible
### Dépendances principales
- **pysnmp >= 7.0.0** : Bibliothèque SNMP avec nouvelle API asynchrone
- **paho-mqtt >= 2.0.0** : Client MQTT pour la communication avec le broker
- **PyYAML >= 6.0.0** : Parsing des fichiers de configuration YAML
⚠️ **Notes importantes sur les versions** :
- **pysnmp 7.x** : Changements d'API incompatibles avec les versions 6.x et antérieures. L'ancienne classe `Slim` a été supprimée au profit de `get_cmd()` avec des objets `SnmpEngine`, `UdpTransportTarget`, etc.
- **paho-mqtt 2.x** : Nouvelle API de callbacks (VERSION2) qui remplace l'ancienne API deprecated (VERSION1). Les signatures des callbacks ont changé.
### Configuration de l'environnement
```bash
# Cloner le repository
git clone <url-du-repo>
cd snmp2mqtt
# Créer un environnement virtuel
python -m venv venv
source venv/bin/activate # Sur Linux/Mac
# ou
venv\Scripts\activate # Sur Windows
# Installer les dépendances
pip install -r requirements.txt
```
## Configuration
### Structure du fichier config.yaml
Le script utilise un fichier de configuration YAML avec la structure suivante :
```yaml
# Configuration du broker MQTT
mqtt:
broker: "192.168.10.202" # Adresse IP du broker MQTT
port: 1883 # Port du broker MQTT
user: "snmp2mqtt" # Nom d'utilisateur MQTT
password: "votre_mot_de_passe" # Mot de passe MQTT
# Intervalle entre les requêtes SNMP (optionnel, défaut: 2 secondes)
sleep_interval: 2
# Configuration des équipements
devices:
nom_equipement:
ip: "192.168.1.1" # Adresse IP de l'équipement
snmp_community: "public" # Communauté SNMP
oids: # Liste des OID à surveiller
- name: "interface_in" # Nom unique du capteur
oid: ".1.3.6.1.2.1.2.2.1.10.1" # OID SNMP
type: "int" # Type de données (int, bool, str)
HA_device_class: "data_size" # Classe d'équipement HA
HA_platform: "sensor" # Plateforme HA
HA_unit: "bit" # Unité (optionnel)
```
### Paramètres de configuration détaillés
#### Section `mqtt`
| Paramètre | Type | Obligatoire | Description |
|-----------|------|-------------|-------------|
| `broker` | string | ✅ | Adresse IP ou nom d'hôte du broker MQTT |
| `port` | integer | ✅ | Port du broker MQTT (généralement 1883) |
| `user` | string | ✅ | Nom d'utilisateur pour l'authentification MQTT |
| `password` | string | ✅ | Mot de passe pour l'authentification MQTT |
#### Section `devices`
Chaque équipement est défini par une clé (nom de l'équipement) et les paramètres suivants :
| Paramètre | Type | Obligatoire | Description |
|-----------|------|-------------|-------------|
| `ip` | string | ✅ | Adresse IP de l'équipement SNMP |
| `snmp_community` | string | ✅ | Communauté SNMP (généralement "public") |
| `oids` | liste | ✅ | Liste des OID SNMP à surveiller |
#### Configuration des OID
Chaque OID dans la liste `oids` doit contenir :
| Paramètre | Type | Obligatoire | Description |
|-----------|------|-------------|-------------|
| `name` | string | ✅ | Identifiant unique pour ce capteur |
| `oid` | string | ✅ | Identifiant d'objet SNMP |
| `type` | string | ✅ | Type de conversion Python ("int", "bool", "str") |
| `HA_device_class` | string | ✅ | Classe d'équipement Home Assistant |
| `HA_platform` | string | ✅ | Plateforme Home Assistant ("sensor", "binary_sensor") |
| `HA_unit` | string | ❌ | Unité de mesure pour le capteur |
| `operation` | string | ❌ | Opération mathématique à appliquer (ex: "value / 1000") |
### Classes d'équipements Home Assistant courantes
- `data_size` : Pour les données de trafic réseau
- `connectivity` : Pour le statut des interfaces
- `power_factor` : Pour les pourcentages (CPU, utilisation)
- `temperature` : Pour les températures
- `signal_strength` : Pour la qualité du signal
### OID SNMP couramment utilisés
```yaml
# Interfaces réseau (remplacez X par l'index de l'interface)
- ".1.3.6.1.2.1.2.2.1.10.X" # Octets entrants sur l'interface X
- ".1.3.6.1.2.1.2.2.1.16.X" # Octets sortants sur l'interface X
- ".1.3.6.1.2.1.2.2.1.8.X" # Statut opérationnel (1=actif, 2=inactif)
- ".1.3.6.1.2.1.2.2.1.2.X" # Description de l'interface
# MikroTik spécifique
- ".1.3.6.1.4.1.14988.1.1.3.14.0" # Utilisation CPU
- ".1.3.6.1.4.1.14988.1.1.6.1.0" # Température
```
## Utilisation
### Lancement du script
```bash
# Activer l'environnement virtuel
source venv/bin/activate
# Lancer avec le fichier de configuration
python snmp2mqtt.py --config config.yaml
# Ou avec la forme courte
python snmp2mqtt.py -c config.yaml
```
### Options en ligne de commande
| Option | Description |
|--------|-------------|
| `--config`, `-c` | Chemin vers le fichier de configuration YAML (obligatoire) |
| `--help`, `-h` | Affiche l'aide |
## Intégration Home Assistant
### MQTT Discovery (Découverte automatique)
Le script implémente le protocole **MQTT Discovery** de Home Assistant pour une intégration transparente et automatique. Aucune configuration manuelle n'est nécessaire dans Home Assistant.
#### Fonctionnement de l'autodécouverte
1. **Au démarrage** : Publication des configurations de découverte
2. **Pendant l'exécution** : Mise à jour continue des états des capteurs
3. **Surveillance** : Gestion des statuts de disponibilité (online/offline)
### Topics MQTT générés
#### Topics de découverte (Discovery)
Chaque capteur génère un topic de configuration individuel :
```
homeassistant/{platform}/{node_id}/{object_id}/config
```
**Exemples** :
```bash
# Capteur de trafic réseau
homeassistant/sensor/mikrotik_hex/mikrotik_hex_starlink_in/config
# Statut de connectivité
homeassistant/binary_sensor/mikrotik_hex/mikrotik_hex_starlink_status/config
```
#### Topics de données
- **État** : `SNMP/{device_name}/state` - Données JSON des capteurs
- **Disponibilité** : `SNMP/{device_name}/availability` - Statut online/offline
### Configuration automatique des capteurs
Chaque capteur est configuré avec :
```json
{
"name": "mikrotik_hex starlink_in",
"unique_id": "mikrotik_hex_192_168_10_2_starlink_in",
"state_topic": "SNMP/mikrotik_hex/state",
"value_template": "{{ value_json.starlink_in }}",
"device_class": "data_size",
"unit_of_measurement": "bit",
"icon": "mdi:network",
"device": {
"identifiers": ["snmp2mqtt_mikrotik_hex_192_168_10_2"],
"name": "mikrotik_hex",
"model": "SNMP Device",
"manufacturer": "Network Equipment"
},
"availability": {
"topic": "SNMP/mikrotik_hex/availability",
"payload_available": "online",
"payload_not_available": "offline"
}
}
```
### Découverte automatique dans Home Assistant
#### Regroupement par équipement
Tous les capteurs d'un même équipement sont automatiquement regroupés sous une seule carte d'équipement :
- **Identifiant unique** basé sur `device_name` + `ip`
- **Nom d'affichage** basé sur le nom de l'équipement
- **Métadonnées** : modèle, fabricant, version du logiciel
#### Types de capteurs supportés
| Type HA | Platform | Description | Icône |
|---------|----------|-------------|-------|
| `sensor` | `sensor` | Valeurs numériques (trafic, CPU, etc.) | Selon device_class |
| `binary_sensor` | `binary_sensor` | États binaires (online/offline, actif/inactif) | mdi:network-outline |
#### Classes d'équipements et icônes
| Device Class | Utilisation | Icône Auto | Unité Suggérée |
|--------------|-------------|------------|----------------|
| `data_size` | Trafic réseau, volumes de données | mdi:network | bit, byte, MB, GB |
| `connectivity` | Statut des interfaces, connexions | mdi:network-outline | - |
| `power_factor` | Pourcentages (CPU, mémoire) | mdi:gauge | % |
| `temperature` | Températures d'équipements | mdi:thermometer | °C, °F |
| `signal_strength` | Qualité des signaux | mdi:signal | dBm, % |
### Surveillance de disponibilité
#### Statuts de disponibilité
- **Online** : Équipement accessible et données mises à jour
- **Offline** : Équipement inaccessible ou erreurs SNMP
#### Mécanisme de heartbeat
- Mise à jour du statut à chaque cycle de surveillance
- Marquage offline automatique en cas d'erreur
- Statut offline lors de l'arrêt du script
### Persistance et redémarrages
#### Configuration Discovery retenue
- **Flag retain=true** sur les topics de configuration
- **Redécouverte automatique** après redémarrage de Home Assistant
- **Pas de perte de configuration** lors des redémarrages
#### Données d'état temps réel
- **Flag retain=false** sur les données d'état
- **Données fraîches uniquement** après redémarrage
- **Historique préservé** par Home Assistant
### Intégration dans l'interface Home Assistant
Après démarrage du script, vous verrez automatiquement :
1. **Page Équipements** : Nouveaux équipements SNMP avec leurs capteurs
2. **États et Historiques** : Données temps réel et graphiques
3. **Cartes automatiques** : Ajout facile aux tableaux de bord
4. **Notifications** : Alertes sur les changements d'état
5. **Automations** : Utilisation des capteurs dans les règles
### Exemple d'équipement découvert
```
📱 mikrotik_hex (SNMP Device)
├── 📊 mikrotik_hex starlink_in (123.45 MB)
├── 📊 mikrotik_hex starlink_out (67.89 MB)
├── 🔌 mikrotik_hex starlink_status (Online)
├── 📊 mikrotik_hex lan_bridge_in (234.56 MB)
├── 📊 mikrotik_hex lan_bridge_out (78.90 MB)
└── 🔌 mikrotik_hex lan_bridge_status (Online)
Statut: Online | Dernière mise à jour: il y a 2 secondes
```
## Exemple de configuration complète
```yaml
# Configuration MQTT
mqtt:
broker: "192.168.10.202"
port: 1883
user: "snmp2mqtt"
password: "mon_mot_de_passe"
# Intervalle de surveillance
sleep_interval: 5
# Équipements à surveiller
devices:
# Routeur MikroTik Hex
mikrotik_hex:
ip: "192.168.10.2"
snmp_community: "public"
oids:
# Interface Starlink
- name: "starlink_in"
oid: ".1.3.6.1.2.1.2.2.1.10.1"
type: "int"
HA_device_class: "data_size"
HA_platform: "sensor"
HA_unit: "bit"
- name: "starlink_out"
oid: ".1.3.6.1.2.1.2.2.1.16.1"
type: "int"
HA_device_class: "data_size"
HA_platform: "sensor"
HA_unit: "bit"
- name: "starlink_status"
oid: ".1.3.6.1.2.1.2.2.1.8.1"
type: "bool"
HA_device_class: "connectivity"
HA_platform: "binary_sensor"
# Exemple avec transformation de valeur (température en millidegrés -> degrés)
# - name: "temperature"
# oid: ".1.3.6.1.4.1.14988.1.1.6.1.0" # OID pour la température sur MikroTik
# type: "int"
# operation: "value / 1000"
# HA_device_class: "temperature"
# HA_platform: "sensor"
# HA_unit: "°C"
# Switch réseau
switch_bureau:
ip: "192.168.10.5"
snmp_community: "public"
oids:
- name: "port1_status"
oid: ".1.3.6.1.2.1.2.2.1.8.1"
type: "bool"
HA_device_class: "connectivity"
HA_platform: "binary_sensor"
```
## Dépannage
### Problèmes courants
1. **Erreur "Configuration file not found"**
- Vérifiez le chemin vers le fichier config.yaml
- Assurez-vous que le fichier existe et est lisible
2. **Erreur de connexion MQTT**
- Vérifiez les paramètres du broker (IP, port, credentials)
- Testez la connectivité réseau vers le broker
3. **Pas de données SNMP**
- Vérifiez que l'équipement supporte SNMP
- Testez avec `snmpwalk` : `snmpwalk -v2c -c public IP_EQUIPEMENT`
- Vérifiez les OID utilisés
4. **Capteurs non découverts dans Home Assistant**
- Vérifiez que MQTT Discovery est activé dans Home Assistant
- Surveillez les logs MQTT avec `mosquitto_sub`
5. **Erreurs liées à PySNMP**
- **"ModuleNotFoundError: No module named 'pysnmp.hlapi.asyncio.slim'"** : Vous utilisez une version pysnmp 6.x. Mettez à jour vers >= 7.0.0
- **"Please call .create() to construct UdpTransportTarget object"** : Erreur corrigée dans cette version, utilisez `pip install -r requirements.txt`
- **Erreurs d'importation SNMP** : Assurez-vous d'avoir pysnmp 7.x avec `pip show pysnmp`
6. **Erreurs liées à Paho MQTT**
- **"DeprecationWarning: Callback API version 1 is deprecated"** : Vous utilisez une version paho-mqtt < 2.0. Mettez à jour vers >= 2.0.0
- **Erreurs de callback MQTT** : La nouvelle API VERSION2 change la signature des callbacks (ex: `on_connect` reçoit maintenant 5 paramètres)
- **Vérification version** : `pip show paho-mqtt` pour confirmer la version installée
### Commandes de test utiles
```bash
# Test SNMP manuel
snmpwalk -v2c -c public 192.168.10.2 1.3.6.1.2.1.2.2.1.10
# Surveillance des messages MQTT
mosquitto_sub -h 192.168.10.202 -u snmp2mqtt -P 'mot_de_passe' -t 'homeassistant/device/+/config'
mosquitto_sub -h 192.168.10.202 -u snmp2mqtt -P 'mot_de_passe' -t 'SNMP/+/state'
# Test de connectivité
ping 192.168.10.2
ping 192.168.10.202
```
## Support multi-équipements
Le script supporte nativement la surveillance simultanée de plusieurs équipements grâce à une architecture **multi-threading** :
### Fonctionnement
- **Thread indépendant** pour chaque équipement configuré
- **Surveillance parallèle** : tous les équipements sont surveillés simultanément
- **Isolation des erreurs** : la défaillance d'un équipement n'affecte pas les autres
- **Clients MQTT séparés** : chaque thread utilise son propre client MQTT
- **Arrêt gracieux** : tous les threads s'arrêtent proprement sur signal
### Avantages
-**Performance optimale** : pas de blocage entre équipements
- 🔄 **Traitement parallèle** : requêtes SNMP simultanées
- 🛡️ **Robustesse** : isolation des défaillances
- 📊 **Scalabilité** : facilement extensible à des dizaines d'équipements
- 🔧 **Maintenance** : logs clairement identifiés par équipement
### Configuration multi-équipements
```yaml
devices:
routeur_principal:
ip: "192.168.10.1"
snmp_community: "public"
oids:
# ... configuration OID ...
switch_bureau:
ip: "192.168.10.5"
snmp_community: "public"
oids:
# ... configuration OID ...
point_acces_wifi:
ip: "192.168.10.10"
snmp_community: "private"
oids:
# ... configuration OID ...
```
### Logs multi-threading
Chaque thread est clairement identifié dans les logs :
```
(INFO) [Device-routeur_principal] Starting monitoring thread
(INFO) [Device-switch_bureau] MQTT client connected
(DEBUG) [Device-point_acces_wifi] Published state to SNMP/point_acces_wifi/state
```
### Gestion des ressources
- **Clients MQTT uniques** : ID client basé sur le nom de l'équipement
- **Topics séparés** : chaque équipement a ses propres topics MQTT
- **Discovery HA indépendante** : configuration Home Assistant par équipement
- **Disponibilité individuelle** : statut online/offline par équipement
## Logs et debugging
Le script utilise le module `logging` de Python avec le niveau INFO par défaut. Les logs incluent :
- Chargement de la configuration
- Connexions MQTT
- Requêtes SNMP et leurs résultats
- Publication des messages MQTT
- Erreurs et avertissements
## Licence
Ce projet est distribué sous licence libre. Consultez le fichier LICENSE pour plus de détails.

39
SYSTEMD.md Normal file
View File

@@ -0,0 +1,39 @@
# Service systemd pour snmp2mqtt
## Installation
Copiez le fichier dans `/etc/systemd/system/` :
```bash
sudo cp snmp2mqtt.service /etc/systemd/system/
sudo cp snmp-discover.service /etc/systemd/system/
```
Créez un utilisateur dédié :
```bash
sudo useradd -r -s /bin/false snmp2mqtt
sudo chown -R snmp2mqtt:snmp2mqtt /home/snmp2mqtt/snmp2mqtt
```
Rechargez systemd et activez le service :
```bash
sudo systemctl daemon-reload
sudo systemctl enable snmp2mqtt
sudo systemctl start snmp2mqtt
```
## Journalisation
```bash
journalctl -u snmp2mqtt -f
```
## Pour snmp-discover
Le service `snmp-discover.service` est de type `oneshot` et ne doit pas être activé automatiquement. Il est destiné à être lancé manuellement pour configurer les appareils :
```bash
sudo systemctl start snmp-discover
```

68
config.yaml Normal file
View File

@@ -0,0 +1,68 @@
# SNMP2MQTT Configuration File
# This file contains the configuration for the SNMP to MQTT bridge
# MQTT Broker Configuration
mqtt:
broker: "IP or FQDN"
port: 1883
user: "USER"
password: "PASSWORD"
# Optional: Sleep interval between SNMP polls (default: 2 seconds)
sleep_interval: 2
# Device Configurations
# You can define multiple devices here. Each device will be monitored independently.
devices:
# Device name (used for MQTT topics and Home Assistant device identification)
mikrotik_hex:
ip: "IP"
snmp_community: "public"
oids:
# example interface index 1
- name: "if1_in"
oid: ".1.3.6.1.2.1.2.2.1.10.1"
type: "int"
HA_device_class: "data_size"
HA_platform: "sensor"
HA_unit: "bit"
- name: "if1_out"
oid: ".1.3.6.1.2.1.2.2.1.16.1"
type: "int"
HA_device_class: "data_size"
HA_platform: "sensor"
HA_unit: "bit"
- name: "if1_status"
oid: ".1.3.6.1.2.1.2.2.1.8.1"
type: "bool"
HA_device_class: "connectivity"
HA_platform: "binary_sensor"
# Example of a temperature sensor that returns the value in millidegrees.
# The 'operation' key allows performing a simple calculation.
# The placeholder 'value' will be replaced by the SNMP value.
# - name: "temperature"
# oid: ".1.3.6.1.4.1.XXXX.1.1.1.5.1.3.1" # Example OID
# type: "int"
# operation: "value / 1000"
# HA_device_class: "temperature"
# HA_platform: "sensor"
# HA_unit: "°C"
# OID Configuration Reference:
# - name: Unique identifier for this metric (used in MQTT topics and Home Assistant)
# - oid: SNMP Object Identifier
# - type: Python type for value conversion ("int", "bool", "str")
# - HA_device_class: Home Assistant device class for proper icon/categorization
# Common classes: data_size, connectivity, power_factor, temperature, etc.
# - HA_platform: Home Assistant platform type ("sensor", "binary_sensor")
# - HA_unit: (optional) Unit of measurement for the sensor
# Common units: "bit", "byte", "%", "°C", "°F", etc.
# Common SNMP OIDs for network interfaces:
# - .1.3.6.1.2.1.2.2.1.10.X = Incoming bytes on interface X
# - .1.3.6.1.2.1.2.2.1.16.X = Outgoing bytes on interface X
# - .1.3.6.1.2.1.2.2.1.8.X = Interface operational status (1=up, 2=down)
# - .1.3.6.1.2.1.2.2.1.2.X = Interface description

13
requirements.txt Normal file
View File

@@ -0,0 +1,13 @@
# SNMP2MQTT Python Dependencies
# Install with: pip install -r requirements.txt
# SNMP library for asynchronous SNMP operations
# Note: pysnmp 7.x uses a new API structure (no more Slim class)
pysnmp>=7.0.0
# MQTT client library for connecting to MQTT brokers
# Note: paho-mqtt 2.x uses a new callback API (VERSION2) instead of the deprecated VERSION1
paho-mqtt>=2.0.0
# YAML configuration file parsing
PyYAML>=6.0.0

557
snmp-discover.py Normal file
View File

@@ -0,0 +1,557 @@
#!/usr/bin/env python3
"""
snmp-discover.py - Assistant de découverte SNMP semi-guidé pour snmp2mqtt.
Détecte automatiquement les OIDs disponibles sur un appareil MikroTik ou TP-Link Omada
et met à jour le fichier config.yaml.
"""
import asyncio
import yaml
import shutil
import os
import argparse
import sys
from datetime import datetime
from typing import List, Dict, Any, Optional, Tuple
from pysnmp.hlapi.asyncio import (
SnmpEngine,
CommunityData,
UdpTransportTarget,
ContextData,
ObjectIdentity,
ObjectType,
get_cmd,
walk_cmd,
)
# Catalogue des OIDs par type d'appareil
DEVICE_CATALOGS = {
"mikrotik": {
"manufacturer": "MikroTik",
"system_metrics": {
"cpu_usage": {
"oid": ".1.3.6.1.4.1.14988.1.1.3.14.0",
"name_template": "cpu_usage",
"type": "int",
"HA_device_class": "power_factor",
"HA_platform": "sensor",
"HA_unit": "%",
},
"temperature": {
"oid": ".1.3.6.1.4.1.14988.1.1.6.1.0",
"name_template": "temperature",
"type": "int",
"operation": "value / 1000",
"HA_device_class": "temperature",
"HA_platform": "sensor",
"HA_unit": "°C",
},
"memory_used": {
"oid": ".1.3.6.1.4.1.14988.1.1.4.2.0",
"name_template": "memory_used",
"type": "int",
"HA_device_class": "data_size",
"HA_platform": "sensor",
"HA_unit": "MB",
# Note: MikroTik memory OIDs can vary by version, assuming bytes here requires operation or custom handling.
# Standard HOST-RESOURCES-MIB is often used: hrMemorySize (.1.3.6.1.2.1.25.2.2.0)
# But MikroTik has proprietary ones. Let's adjust operation if needed.
# For now, we'll stick to a simple GET, user may need to tweak operation if values are weird.
"operation": "value / 1000000",
},
"memory_free": {
"oid": ".1.3.6.1.4.1.14988.1.1.4.3.0",
"name_template": "memory_free",
"type": "int",
"HA_device_class": "data_size",
"HA_platform": "sensor",
"HA_unit": "MB",
"operation": "value / 1000000",
},
},
"interfaces_root_oid": ".1.3.6.1.2.1.2.2.1.2",
},
"omada": {
"manufacturer": "TP-Link",
"system_metrics": {
"connected_clients": {
"oid": ".1.3.6.1.4.1.11863.10.1.3.0",
"name_template": "connected_clients",
"type": "int",
"HA_device_class": None,
"HA_platform": "sensor",
"HA_unit": "clients",
},
},
"interfaces_root_oid": ".1.3.6.1.2.1.2.2.1.2",
},
}
def parse_arguments():
parser = argparse.ArgumentParser(description='SNMP Discovery Tool for snmp2mqtt')
parser.add_argument('--config', '-c', default='config.yaml',
help='Path to YAML configuration file (default: config.yaml)')
return parser.parse_args()
async def snmp_get(ip, community, oid_str):
"""Récupère la valeur d'un OID spécifique via SNMP."""
snmpEngine = SnmpEngine()
try:
errorIndication, errorStatus, errorIndex, varBinds = await get_cmd(
snmpEngine,
CommunityData(community),
await UdpTransportTarget.create((ip, 161)),
ContextData(),
ObjectType(ObjectIdentity(oid_str))
)
snmpEngine.close_dispatcher()
if errorIndication:
return None, str(errorIndication)
elif errorStatus:
return None, f"{errorStatus.prettyPrint()}"
else:
for varBind in varBinds:
return str(varBind[1]), None
except Exception as e:
return None, str(e)
finally:
snmpEngine.close_dispatcher()
async def snmp_walk(ip, community, oid_prefix):
"""Effectue un SNMP WALK sur une branche OID."""
results = []
snmpEngine = SnmpEngine()
try:
async for errorIndication, errorStatus, errorIndex, varBinds in walk_cmd(
snmpEngine,
CommunityData(community),
await UdpTransportTarget.create((ip, 161)),
ContextData(),
ObjectType(ObjectIdentity(oid_prefix)),
lexicographicMode=True
):
if errorIndication or errorStatus:
break
for varBind in varBinds:
oid_str = str(varBind[0])
# Le test startswith doit gérer les formats avec/sans le point initial
# oid_prefix = ".1.3.6.1.2.1.2.2.1.2", oid_str = "1.3.6.1.2.1.2.2.1.2.1"
oid_str_normalized = oid_str.lstrip(".")
oid_prefix_normalized = oid_prefix.lstrip(".")
if oid_str_normalized.startswith(oid_prefix_normalized):
results.append((oid_str, str(varBind[1])))
except Exception as e:
print(f"Warning: SNMP walk error on {oid_prefix}: {e}")
finally:
snmpEngine.close_dispatcher()
return results
async def detect_device_type(ip, community):
"""Détecte le type d'équipement via sysDescr."""
# .1.3.6.1.2.1.1.1.0 = sysDescr
value, err = await snmp_get(ip, community, ".1.3.6.1.2.1.1.1.0")
if err:
return None, None, err
desc_lower = value.lower()
device_type = "unknown"
if "mikrotik" in desc_lower or "routeros" in desc_lower:
device_type = "mikrotik"
elif "eap" in desc_lower or "tp-link" in desc_lower or "omada" in desc_lower:
device_type = "omada"
return device_type, value, None
async def get_device_name(ip, community, fallback_type):
"""Récupère sysName pour nommer l'appareil, sinon utilise un fallback."""
# .1.3.6.1.2.1.1.5.0 = sysName
value, err = await snmp_get(ip, community, ".1.3.6.1.2.1.1.5.0")
if value and not err:
return value
# Fallback
prefix = fallback_type if fallback_type != "unknown" else "device"
sanitized_ip = ip.replace(".", "_")
# Check if name exists in config to avoid duplicates (simple check)
return f"{prefix}_{sanitized_ip}"
async def discover_interfaces(ip, community) -> List[Dict[str, Any]]:
"""Découvre les interfaces physiques via ifDescr."""
# ifDescr OID base
ifdescr_oid = ".1.3.6.1.2.1.2.2.1.2"
print(f" [+] Scanning interfaces...")
raw_ifs = await snmp_walk(ip, community, ifdescr_oid)
print(f" [DEBUG] Found {len(raw_ifs)} interfaces via walk")
interfaces = []
for oid_full, name in raw_ifs:
# Extraction de l'index (dernière partie de l'OID)
index = oid_full.split(".")[-1]
# Filtrage basique pour ne garder que les interfaces physiques
name_lower = name.lower()
if any(skip in name_lower for skip in ["loopback", "null", "dummy"]):
continue
# Détection des trucs un peu weird (parfois 'vlan', 'bridge' sont utiles, parfois non)
# On garde 'ether', 'sfp', 'wlan', 'eth', 'port', 'bridge', 'lan', 'wan'
if not any(k in name_lower for k in ["ether", "sfp", "wlan", "eth", "port", "bridge", "lan", "wan"]):
# Si on est sur un Omada ou switch, on pourrait avoir des noms génériques
# On reste permissif mais on exclut les trucs vides
if name.strip() == "":
continue
interface = {
"index": index,
"name": name,
"oids": []
}
# Construction des OID pour in, out, status
# in: .10.index, out: .16.index, status: .8.index
prefix = ".1.3.6.1.2.1.2.2.1"
interface["oids"].append({
"name": f"{name}_in",
"oid": f"{prefix}.10.{index}",
"type": "int",
"HA_device_class": "data_size",
"HA_platform": "sensor",
"HA_unit": "bit",
})
interface["oids"].append({
"name": f"{name}_out",
"oid": f"{prefix}.16.{index}",
"type": "int",
"HA_device_class": "data_size",
"HA_platform": "sensor",
"HA_unit": "bit",
})
interface["oids"].append({
"name": f"{name}_status",
"oid": f"{prefix}.8.{index}",
"type": "int", # On peut mettre bool ensuite si on veut map 1->ON
"HA_device_class": "connectivity",
"HA_platform": "binary_sensor",
})
interfaces.append(interface)
print(f" [DEBUG] Returning {len(interfaces)} interfaces")
return interfaces
async def discover_metrics(ip, community, device_type) -> List[Dict[str, Any]]:
"""Teste la disponibilité des OIDs systèmes spécifiques au fabricant."""
catalog = DEVICE_CATALOGS.get(device_type, {}).get("system_metrics", {})
available_metrics = []
if not catalog:
return available_metrics
print(f" [+] Scanning system metrics ({device_type})...")
for key, meta in catalog.items():
oid = meta.get("oid")
if oid:
val, err = await snmp_get(ip, community, oid)
if val and not err:
available_metrics.append({**meta, "key": key})
else:
print(f" - {key}: not available / error")
return available_metrics
def display_catalog(device_name, interfaces, system_metrics, device_type):
"""Affiche le menu interactif de sélection."""
print(f"\n" + "="*60)
print(f" Appareil détecté: {device_type.upper()}"
f"\n Nom: {device_name}")
print("="*60)
all_items = []
# 1. Interfaces
if interfaces:
print(f"\nInterfaces détectées ({len(interfaces)}):")
for idx, iface in enumerate(interfaces):
num = len(all_items) + 1
label = f" [{num}] {iface['name']} (In/Out/Status)"
print(label)
all_items.append({"type": "interface", "data": iface, "label": label})
# 2. Métriques système
if system_metrics:
prefix_map = {
"cpu": "CPU",
"temp": "Température",
"mem": "RAM"
}
print(f"\nMétriques Système:")
for idx, met in enumerate(system_metrics):
num = len(all_items) + 1
# Essayez de deviner un label propre
key_label = met['key'].replace('_', ' ').title()
unit = met.get('HA_unit', '')
op_label = ""
if met.get('operation'):
op_label = " (transformed)"
label = f" [{num}] {key_label} ({unit}){op_label}"
print(label)
all_items.append({"type": "metric", "data": met, "label": label})
if not all_items:
print("\nAucun OID intéressant trouvé.")
return [], []
print("\n" + "-"*40)
print("\nSélectionnez les métriques à surveiller :")
return all_items
def parse_selection(input_str: str, max_num: int) -> List[int]:
"""Parse la sélection utilisateur : '1,3,5-8', 'all', etc."""
indices = set()
if input_str.lower() == "all":
return list(range(1, max_num + 1))
elif input_str.lower() == "none":
return []
parts = input_str.split(",")
for part in parts:
part = part.strip()
if "-" in part:
try:
start, end = part.split("-", 1)
s = int(start)
e = int(end)
for n in range(s, e + 1):
if 1 <= n <= max_num:
indices.add(n)
except ValueError:
continue
else:
try:
n = int(part)
if 1 <= n <= max_num:
indices.add(n)
except ValueError:
continue
return sorted(list(indices))
def backup_config(config_path):
"""Crée une copie de backup du fichier config."""
# On suppose que le path est absolu ou relatif au cwd, on travaille avec le path tel quel
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
bak_path = f"{config_path}.bak.{timestamp}"
try:
# Créer un backup même si le fichier n'existe pas n'a pas de sens,
# mais s'il existe on le copie.
if os.path.exists(config_path):
shutil.copy2(config_path, bak_path)
print(f"[Backup] Sauvegarde créée: {bak_path}")
return True
else:
print(f"[Backup] Pas de fichier existant à sauvegarder, création du fichier.")
return True
except Exception as e:
print(f"[Erreur] Impossible de créer le backup: {e}")
return False
def build_device_oids(interface_data, metrics_data):
"""Construit la liste des objets OID pour le config."""
oids = []
# Ajout des interfaces (groupées)
for if_item in interface_data:
for oid in if_item["data"]["oids"]:
oids.append(oid)
# Ajout des métriques
for met_item in metrics_data:
oid_meta = met_item["data"]
clean_meta = {
"name": oid_meta.get("name_template", "metric"),
"oid": oid_meta["oid"],
"type": oid_meta["type"],
"HA_platform": oid_meta.get("HA_platform"),
}
if oid_meta.get("HA_device_class"):
clean_meta["HA_device_class"] = oid_meta["HA_device_class"]
if oid_meta.get("HA_unit"):
clean_meta["HA_unit"] = oid_meta["HA_unit"]
if "operation" in oid_meta:
clean_meta["operation"] = oid_meta["operation"]
oids.append(clean_meta)
return oids
def add_device_to_config(config_path, device_name, ip, community, device_oids):
"""Ajoute le device au fichier YAML."""
# Structure du nouveau device
new_device = {
"ip": ip,
"snmp_community": community,
"oids": device_oids
}
config = {}
if os.path.exists(config_path):
try:
with open(config_path, 'r') as f:
content = f.read()
if content.strip():
config = yaml.safe_load(content) or {}
except yaml.YAMLError:
# Si le fichier est corrompu, on ne veut pas perdre le backup fait avant
print(f"[Erreur] Le fichier {config_path} semble invalide. Veuillez vérifier le backup.")
return False
# Assurer la structure de base
if 'mqtt' not in config:
config['mqtt'] = {
'broker': 'IP or FQDN',
'port': 1883,
'user': 'admin',
'password': 'password'
}
if 'devices' not in config:
config['devices'] = {}
# Insertion
config['devices'][device_name] = new_device
# Sauvegarde
try:
with open(config_path, 'w') as f:
yaml.dump(config, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
print(f"[-] Device '{device_name}' ajouté avec succès.")
return True
except Exception as e:
print(f"[Erreur] Echec de l'écriture du fichier config: {e}")
return False
async def process_device_loop(config_path):
"""Gère le loop d'ajout de devices."""
while True:
print("\n--- Découverte d'un nouvel appareil ---")
ip = input("Adresse IP de l'appareil : ").strip()
if not ip:
continue
community = input("Communauté SNMP (défaut: public) : ").strip() or "public"
# 1. Détection
print(f"[*] Connexion à {ip}...")
device_type, sysdescr, err = await detect_device_type(ip, community)
if err:
print(f"[!] Erreur SNMP: {err}")
retry = input("Réessayer ? (o/n) : ").lower()
if retry == 'y' or retry == 'o':
continue
else:
break
if device_type == "unknown":
print(f"[!] Type d'appareil inconnu. Description: {sysdescr}")
force_type = input("Forcer le type ? (mikrotik/omada/none) : ").lower()
if force_type in ["mikrotik", "omada"]:
device_type = force_type
else:
print("Annulé.")
break
print(f"[+] Appareil détecté: {device_type}")
# 2. Nom
device_name = await get_device_name(ip, community, device_type)
# Nettoyage du nom pour qu'il soit un key YAML safe
device_name = device_name.replace(" ", "_").lower()
# 3. Découverte Interfaces
interfaces = await discover_interfaces(ip, community)
# 4. Découverte Métriques
system_metrics = await discover_metrics(ip, community, device_type)
# 5. Display & Sélection
catalog_items = display_catalog(device_name, interfaces, system_metrics, device_type)
if not catalog_items:
choice = input("Continuer avec un autre appareil ? (o/n) : ")
if choice.lower() != 'o' and choice.lower() != 'y':
break
continue
max_idx = len(catalog_items)
selection_str = input(f"\nSélection (1-{max_idx}, ranges, 'all', 'none') : ")
selected_indices = parse_selection(selection_str, max_idx)
if not selected_indices:
print("Aucune sélection.")
choice = input("Continuer avec un autre appareil ? (o/n) : ")
if choice.lower() != 'o' and choice.lower() != 'y':
break
continue
# Filtrer les items sélectionnés
selected_interfaces = []
selected_metrics = []
for idx in selected_indices:
# Attention, indices start at 1
item = catalog_items[idx-1]
if item["type"] == "interface":
selected_interfaces.append(item)
else:
selected_metrics.append(item)
# Construction du device yaml
device_oids = build_device_oids(selected_interfaces, selected_metrics)
# 6. Backup & Save
print(f"\n--- Mise à jour de la configuration ---")
if backup_config(config_path):
success = add_device_to_config(config_path, device_name, ip, community, device_oids)
if success:
pass # Already printed success message
else:
print("[!] Veuillez restaurer manuellement depuis le backup si nécessaire.")
else:
print("[!] Annulé car backup impossible.")
# 7. Continuer ?
cont = input("\nAjouter un autre appareil ? (o/n) : ")
if cont.lower() != 'o' and cont.lower() != 'y':
break
def main():
args = parse_arguments()
asyncio.run(process_device_loop(args.config))
if __name__ == "__main__":
main()

15
snmp-discover.service Normal file
View File

@@ -0,0 +1,15 @@
[Unit]
Description=SNMP Discovery Tool for snmp2mqtt
Documentation=https://git.antoineve.me/AntoineVe/snmp2mqtt
[Service]
Type=oneshot
User=snmp2mqtt
Group=snmp2mqtt
WorkingDirectory=/home/snmp2mqtt/snmp2mqtt
ExecStart=/home/snmp2mqtt/snmp2mqtt/.venv/bin/python /home/snmp2mqtt/snmp2mqtt/snmp-discover.py --config /home/snmp2mqtt/snmp2mqtt/config.yaml
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

499
snmp2mqtt.py Normal file → Executable file
View File

@@ -1,47 +1,488 @@
#!/bin/env python3
import asyncio
from pysnmp.hlapi.asyncio.slim import Slim
from pysnmp.smi.rfc1902 import ObjectIdentity, ObjectType
from pysnmp.hlapi.asyncio import (
get_cmd, CommunityData, UdpTransportTarget, ContextData,
SnmpEngine, ObjectIdentity, ObjectType
)
import logging
import random
from paho.mqtt import client as mqtt_client
import json
from time import sleep
import yaml
import argparse
import sys
import os
import threading
import signal
import time
logging.basicConfig(
format='(%(levelname)s) %(message)s',
level=logging.DEBUG
format='(%(levelname)s) [%(threadName)s] %(message)s',
level=logging.INFO
)
# Global shutdown flag
shutdown_event = threading.Event()
async def run(req):
for oid in req["oids"]:
with Slim(1) as slim:
errorIndication, errorStatus, errorIndex, varBinds = await slim.get(
req["snmp_community"],
req["ip"],
161,
ObjectType(ObjectIdentity(req["oids"][oid])),
)
class DeviceMonitorThread(threading.Thread):
"""Thread class for monitoring a single device"""
def __init__(self, device_name, device_config, mqtt_config, sleep_interval=2):
super().__init__(name=f"Device-{device_name}")
self.device_name = device_name
self.device_config = device_config
self.mqtt_config = mqtt_config.copy()
self.sleep_interval = sleep_interval
self.daemon = True # Dies when main thread dies
# Create unique client ID for this device
self.mqtt_config['client_id'] = f"snmp-mqtt-{device_name}-{random.randint(0, 1000)}"
# Create device request object
self.req = {
"device_name": device_name,
"ip": device_config["ip"],
"snmp_community": device_config["snmp_community"],
"oids": device_config["oids"]
}
def run(self):
"""Main thread execution"""
logging.info(f"Starting monitoring thread for device: {self.device_name} ({self.device_config['ip']})")
try:
# Setup MQTT connection
client = connect_mqtt(self.mqtt_config)
client.loop_start()
state_topic = f"SNMP/{self.device_name}/state"
availability_topic = f"SNMP/{self.device_name}/availability"
logging.info(f"[{self.device_name}] MQTT client connected")
# Publish Home Assistant autodiscovery configuration (only once on startup)
publish_ha_autodiscovery_config(client, self.req)
# Mark device as available
publish(availability_topic, client, "online", True, 1)
logging.info(f"[{self.device_name}] Starting monitoring loop")
# Main monitoring loop
while not shutdown_event.is_set():
try:
# Get SNMP data and publish state
state = asyncio.run(get_snmp(self.req))
publish(state_topic, client, state, False, 0)
logging.debug(f"[{self.device_name}] Published state to {state_topic}: {json.dumps(state)}")
# Update availability (heartbeat)
publish(availability_topic, client, "online", False, 1)
except Exception as e:
logging.error(f"[{self.device_name}] Error in monitoring loop: {e}")
# Mark as offline on error
publish(availability_topic, client, "offline", False, 1)
# Wait for next iteration or shutdown signal
shutdown_event.wait(timeout=self.sleep_interval)
# Cleanup - mark device as offline
publish(availability_topic, client, "offline", True, 1)
client.loop_stop()
client.disconnect()
logging.info(f"[{self.device_name}] Monitoring thread stopped gracefully")
except Exception as e:
logging.error(f"[{self.device_name}] Fatal error in monitoring thread: {e}")
# Try to mark as offline on fatal error
try:
publish(availability_topic, client, "offline", True, 1)
client.disconnect()
except:
pass
logging.info(f"[{self.device_name}] Thread {self.name} finished")
def parse_arguments():
"""Parse command line arguments"""
parser = argparse.ArgumentParser(description='SNMP to MQTT bridge for Home Assistant')
parser.add_argument('--config', '-c', required=True,
help='Path to YAML configuration file')
return parser.parse_args()
def load_config(config_path):
"""Load and validate YAML configuration file"""
if not os.path.exists(config_path):
logging.error(f"Configuration file not found: {config_path}")
sys.exit(1)
try:
with open(config_path, 'r') as file:
config = yaml.safe_load(file)
except yaml.YAMLError as e:
logging.error(f"Error parsing YAML configuration: {e}")
sys.exit(1)
except Exception as e:
logging.error(f"Error reading configuration file: {e}")
sys.exit(1)
# Validate required configuration sections
if 'mqtt' not in config:
logging.error("Missing 'mqtt' section in configuration")
sys.exit(1)
if 'devices' not in config:
logging.error("Missing 'devices' section in configuration")
sys.exit(1)
# Validate MQTT configuration
required_mqtt_fields = ['broker', 'port', 'user', 'password']
for field in required_mqtt_fields:
if field not in config['mqtt']:
logging.error(f"Missing required MQTT field: {field}")
sys.exit(1)
# Validate device configurations
for device_name, device_config in config['devices'].items():
required_device_fields = ['ip', 'snmp_community', 'oids']
for field in required_device_fields:
if field not in device_config:
logging.error(f"Missing required field '{field}' in device '{device_name}'")
sys.exit(1)
# Validate OID configurations
for oid in device_config['oids']:
required_oid_fields = ['name', 'oid', 'type', 'HA_device_class', 'HA_platform']
for field in required_oid_fields:
if field not in oid:
logging.error(f"Missing required OID field '{field}' in device '{device_name}'")
sys.exit(1)
# Convert type string to actual Python type
if oid['type'] == 'int':
oid['type'] = int
elif oid['type'] == 'bool':
oid['type'] = bool
elif oid['type'] == 'str':
oid['type'] = str
else:
logging.error(f"Unsupported type '{oid['type']}' for OID '{oid['name']}' in device '{device_name}'")
sys.exit(1)
logging.info(f"Configuration loaded successfully from {config_path}")
return config
def connect_mqtt(mqtt_config):
def on_connect(client, userdata, connect_flags, reason_code, properties):
if reason_code == 0:
logging.info("Connected to MQTT Broker!")
else:
logging.error(f"Failed to connect to MQTT Broker, reason code: {reason_code}")
# Use the new callback API version 2
client = mqtt_client.Client(callback_api_version=mqtt_client.CallbackAPIVersion.VERSION2)
client.username_pw_set(mqtt_config["user"], mqtt_config["password"])
client.on_connect = on_connect
client.connect(mqtt_config['broker'], mqtt_config['port'])
return client
def publish(topic, client, data, retain, qos):
if isinstance(data, str):
msg = data
else:
msg = json.dumps(data)
result = client.publish(topic=topic, payload=msg, qos=qos, retain=bool(retain))
status = result[0]
if status == 0:
logging.debug(f"Send `{msg}` to topic `{topic}`")
else:
logging.error(f"Failed to send message to topic {topic}")
def apply_operation(value, operation_str):
"""
Applies a simple mathematical operation to a value.
e.g., operation_str = "value / 1000"
"""
if 'value' not in operation_str:
logging.error(f"Invalid operation string: 'value' placeholder missing in '{operation_str}'")
return value
expression = operation_str.replace('value', str(value))
try:
parts = expression.split()
if len(parts) != 3:
logging.error(f"Invalid operation format: '{operation_str}'. Expected 'value <operator> <operand>'")
return value
val = float(parts[0])
operator = parts[1]
operand = float(parts[2])
if operator == '+':
return val + operand
elif operator == '-':
return val - operand
elif operator == '*':
return val * operand
elif operator == '/':
if operand == 0:
logging.warning(f"Attempted division by zero in operation: {operation_str}")
return value
return val / operand
else:
logging.error(f"Unsupported operator: '{operator}' in '{operation_str}'")
return value
except (ValueError, IndexError) as e:
logging.error(f"Could not parse operation string: '{operation_str}'. Error: {e}")
return value
async def get_snmp(req):
"""Asynchronously retrieve SNMP data from device using new pysnmp API"""
data = {}
# Create SNMP engine and transport target
snmpEngine = SnmpEngine()
authData = CommunityData(req["snmp_community"])
transportTarget = await UdpTransportTarget.create((req["ip"], 161))
contextData = ContextData()
for oid in req["oids"]:
try:
# Perform async SNMP GET operation
errorIndication, errorStatus, errorIndex, varBinds = await get_cmd(
snmpEngine,
authData,
transportTarget,
contextData,
ObjectType(ObjectIdentity(oid["oid"]))
)
if errorIndication:
logging.error(errorIndication)
logging.error(f"{req['device_name']} SNMP error indication: {errorIndication}")
continue
elif errorStatus:
logging.error(
"{} at {}".format(
errorStatus.prettyPrint(),
errorIndex and varBinds[int(errorIndex) - 1][0] or "?",
)
f"{req['device_name']} SNMP error status: {errorStatus.prettyPrint()} at {errorIndex and varBinds[int(errorIndex) - 1][0] or '?'}"
)
continue
else:
for varBind in varBinds:
logging.debug(f"SNMP/{req['mqtt_topic']}/{oid} => {varBind[1]}")
raw_value = varBind[1]
# Handle different SNMP value types
if oid['type'] == int and hasattr(raw_value, '__bytes__'):
# Convert OctetString/bytes to int
# For Mikrotik devices, memory values might be in the first 4 bytes
raw_bytes = bytes(raw_value)
# Try to interpret as integer, handling different byte orders
try:
if len(raw_bytes) >= 4:
# Try little-endian first (common for Mikrotik)
value = int.from_bytes(raw_bytes[:4], byteorder='little', signed=False)
else:
value = int.from_bytes(raw_bytes, byteorder='big', signed=False)
except (ValueError, TypeError):
value = raw_value
else:
value = raw_value
logging.debug(f"{req['device_name']} {oid['name']} => {oid['type'](value)}")
# Cast to the right type
value = oid['type'](value)
# Apply operation if defined
if 'operation' in oid:
value = apply_operation(value, oid['operation'])
req = {
"mqtt_topic": "mikrotik_hex",
"ip": "192.168.10.2",
"snmp_community": "public",
"oids": {
"stln_In": "1.3.6.1.2.1.2.2.1.10.12",
"stln_Out": "1.3.6.1.2.1.2.2.1.16.12"
}
if oid['type'] == bool:
if bool(value):
data.update({oid["name"]: "ON"})
else:
data.update({oid["name"]: "OFF"})
else:
data.update({oid["name"]: value})
except ValueError as e:
logging.warning(f"{req['device_name']} OID {oid['oid']} ({oid['name']}) returned an invalid value: {e}")
continue
except Exception as e:
logging.error(f"{req['device_name']} Exception getting OID {oid['oid']}: {e}")
continue
logging.debug(f"{req['device_name']} JSON : {json.dumps(data)}")
return data
def create_ha_device_info(req):
"""Create device information for Home Assistant MQTT Discovery"""
return {
"identifiers": [f"snmp2mqtt_{req['device_name']}_{req['ip']}".replace(".", "_")],
"name": req['device_name'],
"model": "SNMP Device",
"manufacturer": "Network Equipment",
"via_device": "snmp2mqtt"
}
def create_ha_sensor_config(req, oid):
"""Create Home Assistant MQTT Discovery configuration for a single sensor"""
device_info = create_ha_device_info(req)
sensor_id = f"{req['device_name']}_{req['ip']}_{oid['name']}".replace(".", "_")
config = {
"name": f"{req['device_name']} {oid['name']}",
"unique_id": sensor_id,
"state_topic": f"SNMP/{req['device_name']}/state",
"value_template": f"{{{{ value_json.{oid['name']} }}}}",
"device": device_info,
"origin": {
"name": "snmp2mqtt",
"sw_version": "1.0.0",
"support_url": "https://git.antoineve.me/AntoineVe/snmp2mqtt"
}
}
# Add device class if specified
if 'HA_device_class' in oid:
config['device_class'] = oid['HA_device_class']
# Add state_class for total_increasing counters like data size
if oid['HA_device_class'] == 'data_size':
config['state_class'] = 'total_increasing'
# Add unit of measurement if specified
if 'HA_unit' in oid:
config['unit_of_measurement'] = oid['HA_unit']
# Add icon based on device class
icon_mapping = {
'data_size': 'mdi:network',
'connectivity': 'mdi:network-outline',
'power_factor': 'mdi:gauge',
'temperature': 'mdi:thermometer',
'signal_strength': 'mdi:signal'
}
if 'HA_device_class' in oid and oid['HA_device_class'] in icon_mapping:
config['icon'] = icon_mapping[oid['HA_device_class']]
# Add availability topic
config['availability'] = {
"topic": f"SNMP/{req['device_name']}/availability",
"payload_available": "online",
"payload_not_available": "offline"
}
return config
asyncio.run(run(req))
def get_ha_discovery_topic(req, oid):
"""Get the correct Home Assistant MQTT Discovery topic for a sensor"""
platform = oid['HA_platform'] # 'sensor' or 'binary_sensor'
node_id = req['device_name']
object_id = f"{req['device_name']}_{oid['name']}".replace(".", "_")
# Format: homeassistant/<platform>/<node_id>/<object_id>/config
return f"homeassistant/{platform}/{node_id}/{object_id}/config"
def publish_ha_autodiscovery_config(client, req):
"""Publish Home Assistant MQTT Discovery configuration for all sensors of a device"""
logging.info(f"[{req['device_name']}] Publishing Home Assistant autodiscovery configuration")
# Publish availability as online
availability_topic = f"SNMP/{req['device_name']}/availability"
publish(availability_topic, client, "online", True, 1)
# Publish discovery configuration for each OID/sensor
for oid in req['oids']:
config = create_ha_sensor_config(req, oid)
topic = get_ha_discovery_topic(req, oid)
# Publish with retain=True so HA discovers it after restarts
publish(topic, client, config, True, 1)
logging.info(f"[{req['device_name']}] Published discovery config for {oid['name']} to {topic}")
# Small delay to avoid overwhelming the broker
time.sleep(0.1)
def signal_handler(signum, frame):
"""Handle shutdown signals gracefully"""
logging.info(f"Received signal {signum}, initiating graceful shutdown...")
shutdown_event.set()
def process_devices(config):
"""Process multiple devices using threading"""
mqtt_config = config['mqtt'].copy()
sleep_interval = config.get('sleep_interval', 2)
# Setup signal handlers for graceful shutdown
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
device_threads = []
try:
logging.info(f"Starting monitoring for {len(config['devices'])} device(s)")
# Create and start a thread for each device
for device_name, device_config in config['devices'].items():
thread = DeviceMonitorThread(
device_name=device_name,
device_config=device_config,
mqtt_config=mqtt_config,
sleep_interval=sleep_interval
)
device_threads.append(thread)
thread.start()
logging.info(f"Started thread for device: {device_name}")
# Wait for all threads to complete or shutdown signal
while any(thread.is_alive() for thread in device_threads) and not shutdown_event.is_set():
time.sleep(0.5)
except Exception as e:
logging.error(f"Error in process_devices: {e}")
shutdown_event.set()
# Wait for all threads to finish
logging.info("Waiting for all monitoring threads to finish...")
for thread in device_threads:
if thread.is_alive():
thread.join(timeout=5.0) # Wait max 5 seconds per thread
if thread.is_alive():
logging.warning(f"Thread {thread.name} did not stop gracefully")
logging.info("All monitoring threads have finished")
def main():
"""Main entry point"""
args = parse_arguments()
config = load_config(args.config)
logging.info("Starting snmp2mqtt bridge...")
logging.info(f"Configured devices: {list(config['devices'].keys())}")
try:
process_devices(config)
except KeyboardInterrupt:
logging.info("Shutdown requested by user")
except Exception as e:
logging.error(f"Unexpected error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()

19
snmp2mqtt.service Normal file
View File

@@ -0,0 +1,19 @@
[Unit]
Description=SNMP to MQTT bridge for Home Assistant
Documentation=https://git.antoineve.me/AntoineVe/snmp2mqtt
After=network.target
Wants=network.target
[Service]
Type=simple
User=snmp2mqtt
Group=snmp2mqtt
WorkingDirectory=/home/snmp2mqtt/snmp2mqtt
ExecStart=/home/snmp2mqtt/snmp2mqtt/.venv/bin/python /home/snmp2mqtt/snmp2mqtt/snmp2mqtt.py --config /home/snmp2mqtt/snmp2mqtt/config.yaml
Restart=on-failure
RestartSec=10
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target