Compare commits
3 Commits
bf7eedf3ea
...
64bdcc7409
| Author | SHA1 | Date | |
|---|---|---|---|
| 64bdcc7409 | |||
| b3cba0f556 | |||
| 4bb99de7db |
+1
-1
@@ -220,5 +220,5 @@ __marimo__/
|
||||
.streamlit/secrets.toml
|
||||
|
||||
# Ignore monitor.service and config.yaml since they contain local configuration that shouldn't be shared.
|
||||
monitor.service
|
||||
monitor-py.service
|
||||
config.yaml
|
||||
@@ -0,0 +1,198 @@
|
||||
# monitor-py
|
||||
|
||||
A Linux system monitor that publishes machine stats to MQTT with full [Home Assistant MQTT auto-discovery](https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery) support. Each machine running the script appears as a single device in HA with all its sensors grouped underneath it — similar to what IOTLink or HASS.Agent provide on Windows.
|
||||
|
||||
## Features
|
||||
|
||||
- **CPU** — usage %, frequency, temperature, load averages (1m / 5m / 15m)
|
||||
- **GPU** — usage %, temperature, VRAM used/total/%, power draw (NVIDIA via `nvidia-smi`)
|
||||
- **RAM** — usage %, used / available / total GiB, swap usage
|
||||
- **Disk** — per-partition usage %, used / free / total GiB; aggregate read/write speed
|
||||
- **Network** — per-interface download / upload speed
|
||||
- **System** — uptime, logged-in users
|
||||
- **Session idle time** — time since last keyboard/mouse input (requires `xprintidle`)
|
||||
- HA availability tracking — sensors go `unavailable` in HA when the script stops
|
||||
- HA restart resilience — discovery configs are re-sent automatically when HA comes back online
|
||||
|
||||
## Requirements
|
||||
|
||||
**Python packages** (all available via pip):
|
||||
|
||||
| Package | Purpose |
|
||||
|---|---|
|
||||
| `paho-mqtt` | MQTT client |
|
||||
| `psutil` | System stats |
|
||||
| `pyyaml` | Config file parsing |
|
||||
|
||||
**Optional system tools:**
|
||||
|
||||
| Tool | Purpose |
|
||||
|---|---|
|
||||
| `nvidia-smi` | NVIDIA GPU stats (skipped gracefully if absent) |
|
||||
| `xprintidle` | Session idle time (requires X11; skipped gracefully if absent) |
|
||||
|
||||
Install Python dependencies:
|
||||
```bash
|
||||
pip install paho-mqtt psutil pyyaml
|
||||
```
|
||||
|
||||
Install `xprintidle` (Arch / Arch-based):
|
||||
```bash
|
||||
sudo pacman -S xprintidle
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Clone or copy the files
|
||||
|
||||
Place the project directory wherever you like. `/opt/monitor-py` is a sensible system-wide location; your home directory works equally well.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/youruser/monitor-py.git /opt/monitor-py
|
||||
cd /opt/monitor-py
|
||||
```
|
||||
|
||||
### 2. Create your config file
|
||||
|
||||
Copy the example config and edit it:
|
||||
|
||||
```bash
|
||||
cp config.yaml.example config.yaml
|
||||
```
|
||||
|
||||
Open `config.yaml` and at minimum set your broker address and credentials:
|
||||
|
||||
```yaml
|
||||
mqtt:
|
||||
host: "192.168.1.x" # your MQTT broker IP or hostname
|
||||
port: 1883
|
||||
username: "your_user"
|
||||
password: "your_password"
|
||||
topic_prefix: "monitor-py"
|
||||
client_id: "" # auto-generated if left empty
|
||||
|
||||
monitor:
|
||||
interval: 30 # seconds between publishes
|
||||
device_name: "" # human-readable HA device name; defaults to hostname
|
||||
node_id: "" # MQTT topic slug; defaults to hostname if empty
|
||||
|
||||
sensors:
|
||||
cpu: true
|
||||
gpu: true
|
||||
ram: true
|
||||
disk:
|
||||
enabled: true
|
||||
partitions: [] # empty = auto-detect; or list specific mounts: ["/", "/home"]
|
||||
io_stats: true
|
||||
network:
|
||||
enabled: true
|
||||
interfaces: [] # empty = all non-loopback; or list specific: ["enp6s0"]
|
||||
system: true
|
||||
idle_time: true
|
||||
```
|
||||
|
||||
#### `device_name` vs `node_id`
|
||||
|
||||
| Field | What it affects |
|
||||
|---|---|
|
||||
| `device_name` | The display name shown in the Home Assistant device card |
|
||||
| `node_id` | The slug used in MQTT topics and HA's internal unique IDs |
|
||||
|
||||
Both default to the machine's hostname if left empty. You can set them independently — for example `node_id: "tony-desktop"` with `device_name: "Tony's Desktop"`.
|
||||
|
||||
The `node_id` determines the MQTT topic structure:
|
||||
```
|
||||
homeassistant/sensor/{node_id}/{node_id}_{metric}/config ← discovery
|
||||
{topic_prefix}/{node_id}/state ← state payload
|
||||
{topic_prefix}/{node_id}/availability ← LWT
|
||||
```
|
||||
|
||||
#### Disk partition detection
|
||||
|
||||
If `partitions` is empty, the script auto-detects all physical, non-virtual mounts (excludes `tmpfs`, `squashfs`, loop devices, etc.). On machines with many mounts (e.g. Windows drives mounted under `/mnt/`) it is cleaner to list only the partitions you care about:
|
||||
|
||||
```yaml
|
||||
disk:
|
||||
partitions: ["/", "/home", "/mnt/data"]
|
||||
```
|
||||
|
||||
### 3. Test manually
|
||||
|
||||
Run the script directly to confirm it connects and publishes:
|
||||
|
||||
```bash
|
||||
python3 monitor.py
|
||||
```
|
||||
|
||||
You should see log output like:
|
||||
```
|
||||
2026-05-04 12:00:00 [INFO] Connecting to 192.168.1.x:1883 as device 'tony-asus' (interval=30s)...
|
||||
2026-05-04 12:00:00 [INFO] Connected to MQTT broker.
|
||||
2026-05-04 12:00:00 [INFO] Published 42 discovery configs.
|
||||
```
|
||||
|
||||
Open Home Assistant → **Settings → Devices & Services → MQTT** — your machine should appear as a new device with all sensors populated.
|
||||
|
||||
To use a different config file path:
|
||||
```bash
|
||||
python3 monitor.py /path/to/my-config.yaml
|
||||
```
|
||||
|
||||
### 4. Install as a systemd service
|
||||
|
||||
Copy the example service file and edit it:
|
||||
|
||||
```bash
|
||||
cp monitor-py.service.example monitor-py.service
|
||||
```
|
||||
|
||||
Replace the placeholder values in `monitor-py.service`:
|
||||
|
||||
```ini
|
||||
User=YOUR_USERNAME
|
||||
WorkingDirectory=/path/to/monitor-py
|
||||
ExecStart=/usr/bin/python3 /path/to/monitor-py/monitor.py
|
||||
Environment=DISPLAY=:0
|
||||
Environment=XAUTHORITY=/home/YOUR_USERNAME/.Xauthority
|
||||
```
|
||||
|
||||
> **`DISPLAY` and `XAUTHORITY`** are needed only for the `idle_time` sensor. If you are not using that sensor you can remove those two lines. If your display is not `:0`, check with `echo $DISPLAY` in a terminal.
|
||||
|
||||
Install and enable the service:
|
||||
|
||||
```bash
|
||||
sudo cp monitor-py.service /etc/systemd/system/monitor-py.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now monitor-py.service
|
||||
```
|
||||
|
||||
Check the status and logs:
|
||||
|
||||
```bash
|
||||
systemctl status monitor-py
|
||||
journalctl -u monitor-py -f
|
||||
```
|
||||
|
||||
## Verifying discovery on the broker
|
||||
|
||||
To watch all discovery payloads as they arrive:
|
||||
```bash
|
||||
mosquitto_sub -h 192.168.1.x -u your_user -P your_password \
|
||||
-t 'homeassistant/sensor/{node_id}/#' -v
|
||||
```
|
||||
|
||||
## Cleaning up stale entities
|
||||
|
||||
If you change a machine's `node_id`, or remove the script from a machine entirely, the old retained discovery configs will remain on the broker and leave ghost entities in HA. Delete them with an empty retained publish to each config topic.
|
||||
|
||||
Bulk-delete all discovery configs for a given `node_id`:
|
||||
```bash
|
||||
mosquitto_sub -h 192.168.1.x -u your_user -P your_password \
|
||||
-t 'homeassistant/sensor/{node_id}/#' --retained-only -C 999 -F '%t' 2>/dev/null \
|
||||
| xargs -I{} mosquitto_pub -h 192.168.1.x -u your_user -P your_password \
|
||||
-t '{}' -n -r
|
||||
```
|
||||
|
||||
## Running on multiple machines
|
||||
|
||||
Deploy the project directory to each machine, create a `config.yaml` on each, and start the service. Each machine will appear as its own device in HA based on its `node_id` (hostname by default). No other configuration is needed.
|
||||
|
||||
@@ -9,6 +9,7 @@ mqtt:
|
||||
monitor:
|
||||
interval: 15 # seconds between stat publishes
|
||||
device_name: "" # display name in HA; defaults to hostname
|
||||
node_id: "" # MQTT topic slug and unique_id prefix; defaults to hostname if empty
|
||||
|
||||
sensors:
|
||||
cpu: true
|
||||
|
||||
+20
-8
@@ -62,6 +62,7 @@ def load_config(path: Path = CONFIG_PATH) -> dict:
|
||||
mn = cfg["monitor"]
|
||||
mn.setdefault("interval", 30)
|
||||
mn.setdefault("device_name", "")
|
||||
mn.setdefault("node_id", "")
|
||||
|
||||
s = cfg["sensors"]
|
||||
s.setdefault("cpu", True)
|
||||
@@ -711,14 +712,14 @@ class DiscoveryPublisher:
|
||||
def __init__(
|
||||
self,
|
||||
client: mqtt.Client,
|
||||
hostname: str,
|
||||
node_id: str,
|
||||
device_name: str,
|
||||
state_topic: str,
|
||||
availability_topic: str,
|
||||
discovery_prefix: str = "homeassistant",
|
||||
):
|
||||
self._client = client
|
||||
self._hostname = hostname
|
||||
self._node_id = node_id
|
||||
self._device_name = device_name
|
||||
self._state_topic = state_topic
|
||||
self._availability_topic = availability_topic
|
||||
@@ -726,7 +727,7 @@ def __init__(
|
||||
|
||||
def _device_block(self) -> dict:
|
||||
return {
|
||||
"identifiers": [f"{self._hostname}_monitor"],
|
||||
"identifiers": [f"{self._node_id}_monitor"],
|
||||
"name": self._device_name,
|
||||
"manufacturer": "monitor.py",
|
||||
"model": "Linux System Monitor",
|
||||
@@ -740,7 +741,7 @@ def publish_all(self, sensor_defs: list[dict]) -> None:
|
||||
|
||||
def _publish_one(self, defn: dict) -> None:
|
||||
key = defn["key"]
|
||||
unique_id = f"{self._hostname}_{key}"
|
||||
unique_id = f"{self._node_id}_{key}"
|
||||
object_id = unique_id
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
@@ -762,7 +763,7 @@ def _publish_one(self, defn: dict) -> None:
|
||||
payload["icon"] = defn["icon"]
|
||||
|
||||
discovery_topic = (
|
||||
f"{self._discovery_prefix}/sensor/{object_id}/config"
|
||||
f"{self._discovery_prefix}/sensor/{self._node_id}/{object_id}/config"
|
||||
)
|
||||
self._client.publish(
|
||||
discovery_topic,
|
||||
@@ -836,22 +837,24 @@ def __init__(self, cfg: dict):
|
||||
self._cfg = cfg
|
||||
self._hostname = socket.gethostname()
|
||||
self._device_name = cfg["monitor"].get("device_name") or self._hostname
|
||||
raw_node_id = cfg["monitor"].get("node_id", "").strip()
|
||||
self._node_id = self._make_node_id(raw_node_id or self._hostname)
|
||||
self._interval = max(5, int(cfg["monitor"].get("interval", 30)))
|
||||
|
||||
prefix = cfg["mqtt"]["topic_prefix"]
|
||||
self._state_topic, self._avail_topic = make_topics(prefix, self._hostname)
|
||||
self._state_topic, self._avail_topic = make_topics(prefix, self._node_id)
|
||||
|
||||
self._collectors = self._build_collectors()
|
||||
self._sensor_defs = all_sensor_defs(self._collectors, self._hostname)
|
||||
|
||||
self._client = build_client(cfg, self._hostname)
|
||||
self._client = build_client(cfg, self._node_id)
|
||||
self._client.on_connect = self._on_connect
|
||||
self._client.on_disconnect = self._on_disconnect
|
||||
self._client.on_message = self._on_message
|
||||
|
||||
self._discovery = DiscoveryPublisher(
|
||||
self._client,
|
||||
self._hostname,
|
||||
self._node_id,
|
||||
self._device_name,
|
||||
self._state_topic,
|
||||
self._avail_topic,
|
||||
@@ -862,6 +865,15 @@ def __init__(self, cfg: dict):
|
||||
# Warm up cpu_percent (first call always returns 0.0)
|
||||
psutil.cpu_percent(interval=None)
|
||||
|
||||
@staticmethod
|
||||
def _make_node_id(name: str) -> str:
|
||||
"""Sanitize a string for use in MQTT topics and HA unique_ids."""
|
||||
safe = "".join(c if c.isalnum() or c in "-_" else "-" for c in name.lower())
|
||||
# Collapse consecutive hyphens and strip leading/trailing separators
|
||||
while "--" in safe:
|
||||
safe = safe.replace("--", "-")
|
||||
return safe.strip("-_") or "monitor"
|
||||
|
||||
def _build_collectors(self) -> list:
|
||||
s = self._cfg["sensors"]
|
||||
collectors = []
|
||||
|
||||
Reference in New Issue
Block a user