Initial commit: LAN Checker
Network health monitoring script with MQTT reporting for Home Assistant. - Ping, HTTP, and SNMP checkers - MQTT Discovery for automatic entity creation - Configurable check intervals Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
config.yaml
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
39
CLAUDE.md
Normal file
39
CLAUDE.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
LAN Checker is a Python script that monitors network services and devices, publishing status via MQTT to Home Assistant using MQTT Discovery.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Setup config
|
||||
cp config.yaml.example config.yaml
|
||||
|
||||
# Run
|
||||
python lan_checker.py
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- `lan_checker.py` - Main script: config loading, MQTT client, check scheduler
|
||||
- `checkers/` - Modular check implementations
|
||||
- `base.py` - `BaseChecker` abstract class and `CheckResult` dataclass
|
||||
- `ping.py`, `http.py`, `snmp.py` - Concrete checker implementations
|
||||
- `config.yaml.example` - Configuration template (copy to `config.yaml`)
|
||||
|
||||
## Adding a New Checker
|
||||
|
||||
1. Create `checkers/newtype.py` inheriting from `BaseChecker`
|
||||
2. Implement `check()` method returning `CheckResult`
|
||||
3. Register in `checkers/__init__.py` CHECKERS dict
|
||||
|
||||
## MQTT Topics
|
||||
|
||||
- Discovery: `homeassistant/binary_sensor/lan_checker_{id}/config`
|
||||
- State: `lan_checker/{id}/state` (JSON with state, message, response_time, last_check)
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Antoine
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
9
checkers/__init__.py
Normal file
9
checkers/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from .ping import PingChecker
|
||||
from .http import HttpChecker
|
||||
from .snmp import SnmpChecker
|
||||
|
||||
CHECKERS = {
|
||||
"ping": PingChecker,
|
||||
"http": HttpChecker,
|
||||
"snmp": SnmpChecker,
|
||||
}
|
||||
21
checkers/base.py
Normal file
21
checkers/base.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckResult:
|
||||
success: bool
|
||||
message: str
|
||||
response_time: float | None = None
|
||||
details: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class BaseChecker(ABC):
|
||||
def __init__(self, name: str, config: dict):
|
||||
self.name = name
|
||||
self.config = config
|
||||
|
||||
@abstractmethod
|
||||
def check(self) -> CheckResult:
|
||||
pass
|
||||
53
checkers/http.py
Normal file
53
checkers/http.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
from .base import BaseChecker, CheckResult
|
||||
|
||||
|
||||
class HttpChecker(BaseChecker):
|
||||
def check(self) -> CheckResult:
|
||||
url = self.config["url"]
|
||||
method = self.config.get("method", "GET").upper()
|
||||
timeout = self.config.get("timeout", 10)
|
||||
expected_status = self.config.get("expected_status", 200)
|
||||
verify_ssl = self.config.get("verify_ssl", True)
|
||||
headers = self.config.get("headers", {})
|
||||
|
||||
start = time.time()
|
||||
try:
|
||||
response = requests.request(
|
||||
method=method,
|
||||
url=url,
|
||||
timeout=timeout,
|
||||
verify=verify_ssl,
|
||||
headers=headers
|
||||
)
|
||||
response_time = (time.time() - start) * 1000 # ms
|
||||
|
||||
if response.status_code == expected_status:
|
||||
return CheckResult(
|
||||
success=True,
|
||||
message=f"HTTP {response.status_code}",
|
||||
response_time=response_time,
|
||||
details={"status_code": response.status_code}
|
||||
)
|
||||
else:
|
||||
return CheckResult(
|
||||
success=False,
|
||||
message=f"Unexpected status: {response.status_code} (expected {expected_status})",
|
||||
response_time=response_time,
|
||||
details={"status_code": response.status_code}
|
||||
)
|
||||
except requests.Timeout:
|
||||
return CheckResult(
|
||||
success=False,
|
||||
message="HTTP timeout",
|
||||
response_time=None
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
return CheckResult(
|
||||
success=False,
|
||||
message=f"HTTP error: {e}",
|
||||
response_time=None
|
||||
)
|
||||
53
checkers/ping.py
Normal file
53
checkers/ping.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import subprocess
|
||||
import time
|
||||
import platform
|
||||
|
||||
from .base import BaseChecker, CheckResult
|
||||
|
||||
|
||||
class PingChecker(BaseChecker):
|
||||
def check(self) -> CheckResult:
|
||||
host = self.config["host"]
|
||||
count = self.config.get("count", 1)
|
||||
timeout = self.config.get("timeout", 5)
|
||||
|
||||
# Adapt ping command for OS
|
||||
if platform.system().lower() == "windows":
|
||||
cmd = ["ping", "-n", str(count), "-w", str(timeout * 1000), host]
|
||||
else:
|
||||
cmd = ["ping", "-c", str(count), "-W", str(timeout), host]
|
||||
|
||||
start = time.time()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout + 5
|
||||
)
|
||||
response_time = (time.time() - start) * 1000 # ms
|
||||
|
||||
if result.returncode == 0:
|
||||
return CheckResult(
|
||||
success=True,
|
||||
message="Host is reachable",
|
||||
response_time=response_time
|
||||
)
|
||||
else:
|
||||
return CheckResult(
|
||||
success=False,
|
||||
message="Host is unreachable",
|
||||
response_time=None
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return CheckResult(
|
||||
success=False,
|
||||
message="Ping timeout",
|
||||
response_time=None
|
||||
)
|
||||
except Exception as e:
|
||||
return CheckResult(
|
||||
success=False,
|
||||
message=f"Ping error: {e}",
|
||||
response_time=None
|
||||
)
|
||||
62
checkers/snmp.py
Normal file
62
checkers/snmp.py
Normal file
@@ -0,0 +1,62 @@
|
||||
import time
|
||||
|
||||
from pysnmp.hlapi import (
|
||||
SnmpEngine,
|
||||
CommunityData,
|
||||
UdpTransportTarget,
|
||||
ContextData,
|
||||
ObjectType,
|
||||
ObjectIdentity,
|
||||
getCmd,
|
||||
)
|
||||
|
||||
from .base import BaseChecker, CheckResult
|
||||
|
||||
|
||||
class SnmpChecker(BaseChecker):
|
||||
def check(self) -> CheckResult:
|
||||
host = self.config["host"]
|
||||
port = self.config.get("port", 161)
|
||||
community = self.config.get("community", "public")
|
||||
oid = self.config.get("oid", "1.3.6.1.2.1.1.1.0") # sysDescr
|
||||
timeout_val = self.config.get("timeout", 5)
|
||||
|
||||
start = time.time()
|
||||
try:
|
||||
iterator = getCmd(
|
||||
SnmpEngine(),
|
||||
CommunityData(community),
|
||||
UdpTransportTarget((host, port), timeout=timeout_val, retries=1),
|
||||
ContextData(),
|
||||
ObjectType(ObjectIdentity(oid))
|
||||
)
|
||||
|
||||
error_indication, error_status, error_index, var_binds = next(iterator)
|
||||
response_time = (time.time() - start) * 1000 # ms
|
||||
|
||||
if error_indication:
|
||||
return CheckResult(
|
||||
success=False,
|
||||
message=f"SNMP error: {error_indication}",
|
||||
response_time=None
|
||||
)
|
||||
elif error_status:
|
||||
return CheckResult(
|
||||
success=False,
|
||||
message=f"SNMP error: {error_status.prettyPrint()}",
|
||||
response_time=None
|
||||
)
|
||||
else:
|
||||
values = {str(oid): str(val) for oid, val in var_binds}
|
||||
return CheckResult(
|
||||
success=True,
|
||||
message="SNMP response OK",
|
||||
response_time=response_time,
|
||||
details=values
|
||||
)
|
||||
except Exception as e:
|
||||
return CheckResult(
|
||||
success=False,
|
||||
message=f"SNMP error: {e}",
|
||||
response_time=None
|
||||
)
|
||||
52
config.yaml.example
Normal file
52
config.yaml.example
Normal file
@@ -0,0 +1,52 @@
|
||||
# LAN Checker Configuration
|
||||
|
||||
mqtt:
|
||||
host: "192.168.1.100"
|
||||
port: 1883
|
||||
username: ""
|
||||
password: ""
|
||||
client_id: "lan_checker"
|
||||
|
||||
# Default interval between checks in seconds
|
||||
default_interval: 60
|
||||
|
||||
checks:
|
||||
# Ping checks
|
||||
- id: router
|
||||
name: "Router"
|
||||
type: ping
|
||||
host: "192.168.1.1"
|
||||
interval: 30
|
||||
timeout: 5
|
||||
|
||||
- id: nas
|
||||
name: "NAS Synology"
|
||||
type: ping
|
||||
host: "192.168.1.50"
|
||||
interval: 60
|
||||
|
||||
# HTTP checks
|
||||
- id: homeassistant
|
||||
name: "Home Assistant"
|
||||
type: http
|
||||
url: "http://192.168.1.100:8123"
|
||||
expected_status: 200
|
||||
timeout: 10
|
||||
interval: 60
|
||||
|
||||
- id: plex
|
||||
name: "Plex Server"
|
||||
type: http
|
||||
url: "http://192.168.1.50:32400/web"
|
||||
expected_status: 200
|
||||
verify_ssl: false
|
||||
interval: 120
|
||||
|
||||
# SNMP checks
|
||||
- id: switch
|
||||
name: "Switch Managed"
|
||||
type: snmp
|
||||
host: "192.168.1.2"
|
||||
community: "public"
|
||||
oid: "1.3.6.1.2.1.1.1.0" # sysDescr
|
||||
interval: 120
|
||||
218
lan_checker.py
Normal file
218
lan_checker.py
Normal file
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
LAN Checker - Network health monitoring with MQTT reporting for Home Assistant.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
from checkers import CHECKERS
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LanChecker:
|
||||
def __init__(self, config_path: str):
|
||||
self.config = self._load_config(config_path)
|
||||
self.mqtt_client: mqtt.Client | None = None
|
||||
self.running = False
|
||||
self.checks = []
|
||||
|
||||
def _load_config(self, config_path: str) -> dict:
|
||||
path = Path(config_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Configuration file not found: {config_path}")
|
||||
|
||||
with open(path, "r") as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
def _setup_mqtt(self):
|
||||
mqtt_config = self.config["mqtt"]
|
||||
self.mqtt_client = mqtt.Client(
|
||||
mqtt.CallbackAPIVersion.VERSION2,
|
||||
client_id=mqtt_config.get("client_id", "lan_checker")
|
||||
)
|
||||
|
||||
if mqtt_config.get("username"):
|
||||
self.mqtt_client.username_pw_set(
|
||||
mqtt_config["username"],
|
||||
mqtt_config.get("password", "")
|
||||
)
|
||||
|
||||
self.mqtt_client.on_connect = self._on_mqtt_connect
|
||||
self.mqtt_client.on_disconnect = self._on_mqtt_disconnect
|
||||
|
||||
self.mqtt_client.connect(
|
||||
mqtt_config["host"],
|
||||
mqtt_config.get("port", 1883),
|
||||
keepalive=60
|
||||
)
|
||||
self.mqtt_client.loop_start()
|
||||
|
||||
def _on_mqtt_connect(self, client, userdata, flags, reason_code, properties):
|
||||
if reason_code == 0:
|
||||
logger.info("Connected to MQTT broker")
|
||||
self._publish_discovery()
|
||||
else:
|
||||
logger.error(f"MQTT connection failed: {reason_code}")
|
||||
|
||||
def _on_mqtt_disconnect(self, client, userdata, flags, reason_code, properties):
|
||||
logger.warning(f"Disconnected from MQTT broker: {reason_code}")
|
||||
|
||||
def _publish_discovery(self):
|
||||
"""Publish MQTT Discovery messages for Home Assistant."""
|
||||
for check in self.config["checks"]:
|
||||
device_id = check["id"]
|
||||
device_name = check["name"]
|
||||
|
||||
# Binary sensor for online/offline status
|
||||
status_config = {
|
||||
"name": f"{device_name} Status",
|
||||
"unique_id": f"lan_checker_{device_id}_status",
|
||||
"state_topic": f"lan_checker/{device_id}/state",
|
||||
"value_template": "{{ value_json.state }}",
|
||||
"payload_on": "online",
|
||||
"payload_off": "offline",
|
||||
"device_class": "connectivity",
|
||||
"device": {
|
||||
"identifiers": [f"lan_checker_{device_id}"],
|
||||
"name": device_name,
|
||||
"manufacturer": "LAN Checker",
|
||||
},
|
||||
}
|
||||
|
||||
self.mqtt_client.publish(
|
||||
f"homeassistant/binary_sensor/lan_checker_{device_id}/config",
|
||||
json.dumps(status_config),
|
||||
retain=True
|
||||
)
|
||||
|
||||
# Sensor for response time
|
||||
latency_config = {
|
||||
"name": f"{device_name} Latency",
|
||||
"unique_id": f"lan_checker_{device_id}_latency",
|
||||
"state_topic": f"lan_checker/{device_id}/state",
|
||||
"value_template": "{{ value_json.response_time | default('unavailable') }}",
|
||||
"unit_of_measurement": "ms",
|
||||
"device_class": "duration",
|
||||
"state_class": "measurement",
|
||||
"device": {
|
||||
"identifiers": [f"lan_checker_{device_id}"],
|
||||
"name": device_name,
|
||||
"manufacturer": "LAN Checker",
|
||||
},
|
||||
}
|
||||
|
||||
self.mqtt_client.publish(
|
||||
f"homeassistant/sensor/lan_checker_{device_id}_latency/config",
|
||||
json.dumps(latency_config),
|
||||
retain=True
|
||||
)
|
||||
|
||||
logger.info(f"Published discovery for: {device_name}")
|
||||
|
||||
def _setup_checks(self):
|
||||
"""Initialize check instances from configuration."""
|
||||
for check_config in self.config["checks"]:
|
||||
check_type = check_config["type"]
|
||||
if check_type not in CHECKERS:
|
||||
logger.error(f"Unknown check type: {check_type}")
|
||||
continue
|
||||
|
||||
checker_class = CHECKERS[check_type]
|
||||
checker = checker_class(check_config["name"], check_config)
|
||||
self.checks.append({
|
||||
"id": check_config["id"],
|
||||
"name": check_config["name"],
|
||||
"checker": checker,
|
||||
"interval": check_config.get("interval", self.config.get("default_interval", 60)),
|
||||
"last_check": 0,
|
||||
})
|
||||
|
||||
def _run_check(self, check: dict):
|
||||
"""Execute a single check and publish results."""
|
||||
result = check["checker"].check()
|
||||
|
||||
state = "online" if result.success else "offline"
|
||||
payload = {
|
||||
"state": state,
|
||||
"message": result.message,
|
||||
"response_time": round(result.response_time, 2) if result.response_time else None,
|
||||
"last_check": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
}
|
||||
|
||||
if result.details:
|
||||
payload["details"] = result.details
|
||||
|
||||
topic = f"lan_checker/{check['id']}/state"
|
||||
self.mqtt_client.publish(topic, json.dumps(payload), retain=True)
|
||||
|
||||
log_level = logging.INFO if result.success else logging.WARNING
|
||||
logger.log(log_level, f"{check['name']}: {state} - {result.message}")
|
||||
|
||||
def run(self):
|
||||
"""Main loop."""
|
||||
self._setup_mqtt()
|
||||
self._setup_checks()
|
||||
|
||||
self.running = True
|
||||
logger.info(f"Starting LAN Checker with {len(self.checks)} checks")
|
||||
|
||||
while self.running:
|
||||
current_time = time.time()
|
||||
|
||||
for check in self.checks:
|
||||
if current_time - check["last_check"] >= check["interval"]:
|
||||
self._run_check(check)
|
||||
check["last_check"] = current_time
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
def stop(self):
|
||||
"""Stop the checker gracefully."""
|
||||
logger.info("Stopping LAN Checker...")
|
||||
self.running = False
|
||||
if self.mqtt_client:
|
||||
self.mqtt_client.loop_stop()
|
||||
self.mqtt_client.disconnect()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="LAN Checker - Network health monitoring")
|
||||
parser.add_argument(
|
||||
"-c", "--config",
|
||||
default="config.yaml",
|
||||
help="Path to configuration file (default: config.yaml)"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
checker = LanChecker(args.config)
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
checker.stop()
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
try:
|
||||
checker.run()
|
||||
except Exception as e:
|
||||
logger.error(f"Fatal error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
paho-mqtt>=2.0.0
|
||||
PyYAML>=6.0
|
||||
pysnmp>=4.4.12
|
||||
requests>=2.31.0
|
||||
Reference in New Issue
Block a user