Compare commits

...

3 Commits

Author SHA1 Message Date
tm24fan8 64bdcc7409 Add readme 2026-05-04 20:33:17 -04:00
tm24fan8 b3cba0f556 Cleaner discovery topics, ability to specify own node_id 2026-05-04 20:33:10 -04:00
tm24fan8 4bb99de7db Rename systemd unit to match other files 2026-05-04 20:32:37 -04:00
5 changed files with 220 additions and 9 deletions
+1 -1
View File
@@ -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
+198
View File
@@ -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.
+1
View File
@@ -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
View File
@@ -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 = []