Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ except Exception as ex:

This will install the latest release of `linux2mqtt`, create the necessary MQTT topics, and start sending virtual memory and CPU utilization metrics. The MQTT broker is assumed to be running on `localhost`. If your broker is running on a different host, specify the hostname or IP address using the `--host` parameter.

`linux2mqtt`requires Python 3.11 or above. If your default Python version is older, you may have to explicitly specify the `pip` version by using `pip3` or `pip-3`.
`linux2mqtt`requires Python 3.12 or above. If your default Python version is older, you may have to explicitly specify the `pip` version by using `pip3` or `pip-3`.

* The `--name` parameter is used for the friendly name of the sensor in Home Assistant and for the MQTT topic names. If not specified, it defaults to the hostname of the machine.
* Instantaneous CPU utilization isn't all that informative. It's normal for a CPU to occasionally spike to 100% for a few moments and means that the chip is being utilized to its full potential. However, if the CPU stays pegged at/near 100% over a longer period of time, it is indicative of a bottleneck. The `--cpu=60` parameter is the collection interval for the CPU metrics. Here CPU metrics are gathered for 60 seconds and then the average value is published to MQTT state topic for the sensor. A good value for this option is anywhere between 60 and 1800 seconds (1 to 15 minutes), depending on typical workloads.
Expand Down Expand Up @@ -108,15 +108,25 @@ This will publish network throughput information about Server1's `eth0` interfac

### Package manager updates

`linux2mqtt` can iterate common package managers (currently `Apk` (Alpine), `Apt` (Debian, Ubuntu), `yum` (Centos, Rocky, Fedora)) to enquire about available updates to operating system packages. This provides the number of updates available and lists each updatable package.
`linux2mqtt` can iterate common package managers (currently `Apk` (Alpine), `Apt` (Debian, Ubuntu), `yum` (Centos, Rocky, Fedora)) to enquire about available updates to operating system packages, using the `--packages=` parameter. This provides the number of updates available and lists each updatable package.

By default, `linux2mqtt` will search for available updates every 3600 seconds. This can be changed specifying the desired interval in the parameter.

Enabling this option will cause increased network traffic in order to update package databases.

`linux2mqtt --name Server1 -vvvvv --packages=`
`linux2mqtt --name Server1 -vvvvv --packages=` will search for available updates every 1 hour

`linux2mqtt --name Server1 -vvvvv --packages=7200` will search for available updates every 2 hours

## Logging

`linux2mqtt` can log to a directory in addition to the console using the `--logdir` parameter. The specified directory can be absolute or relative and is created if it doesn't exist. The verbosity parameter applies to file logging and the log file size is limited to 1M bytes and 5 previous files are kept.

`linux2mqtt --name Server1 -vvvvv --logdir /var/log/linux2mqtt/`

## Compatibility

`linux2mqtt` has been tested to work on CentOS, Ubuntu, and Debian (Raspberry Pi), even tough some features are not available everywhere. **Python 3.10 (or above) is recommended.**
`linux2mqtt` has been tested to work on CentOS, Ubuntu, and Debian (Raspberry Pi), even tough some features are not available everywhere. **Python 3.12 (or above) is recommended.**

## Running in the Background (Daemonizing)

Expand Down
194 changes: 148 additions & 46 deletions linux2mqtt/linux2mqtt.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,20 @@
import argparse
import json
import logging
from os import geteuid
from logging.handlers import RotatingFileHandler
from os import geteuid, path
from pathlib import Path
import platform
from queue import Empty, Queue
import signal
import socket
import sys
from threading import Event
import time
from typing import Any

import paho.mqtt.client
import paho.mqtt.enums
import psutil

from . import __version__
Expand Down Expand Up @@ -56,8 +60,6 @@
)
from .type_definitions import Linux2MqttConfig, LinuxDeviceEntry

logging.basicConfig(format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")

main_logger = logging.getLogger("linux2mqtt")


Expand Down Expand Up @@ -94,7 +96,7 @@ class Linux2Mqtt:

cfg: Linux2MqttConfig
metrics: list[BaseMetric]
connected: bool
first_connection_event: Event

mqtt: paho.mqtt.client.Client

Expand Down Expand Up @@ -133,7 +135,7 @@ def __init__(
self.cfg = cfg
self.do_not_exit = do_not_exit
self.metrics = []
self.connected = False
self.first_connection_event = Event()

system_name_sanitized = sanitize(self.cfg["linux2mqtt_hostname"])

Expand Down Expand Up @@ -183,33 +185,53 @@ def connect(self) -> None:
"""
try:
self.mqtt = paho.mqtt.client.Client(
callback_api_version=paho.mqtt.client.CallbackAPIVersion.VERSION2, # type: ignore[attr-defined]
callback_api_version=paho.mqtt.enums.CallbackAPIVersion.VERSION2,
client_id=self.cfg["mqtt_client_id"],
)
if self.cfg["mqtt_user"] or self.cfg["mqtt_password"]:
self.mqtt.username_pw_set(
self.cfg["mqtt_user"], self.cfg["mqtt_password"]
)
self.mqtt.on_connect = self._on_connect
self.mqtt.on_connect_fail = self._on_connect_fail
self.mqtt.on_disconnect = self._on_disconnect
self.mqtt.will_set(
self.status_topic,
"offline",
qos=self.cfg["mqtt_qos"],
retain=True,
)
self.mqtt.connect(
self.mqtt.connect_async(
self.cfg["mqtt_host"], self.cfg["mqtt_port"], self.cfg["mqtt_timeout"]
)
self.mqtt.loop_start()
self._mqtt_send(self.status_topic, "online", retain=True)
self._mqtt_send(self.version_topic, self.version, retain=True)
except paho.mqtt.client.WebsocketConnectionError as ex:
main_logger.exception("Error while trying to connect to MQTT broker.")
main_logger.debug(ex)
raise Linux2MqttConnectionException from ex

def _report_all_statuses(self, status: bool) -> None:
"""Report linux2mqtt and metrics statuses on mqtt.

Parameters
----------
status
The status to set on the status topic

"""
for metric in self.metrics:
self._report_status(
self.availability_topic.format(metric.name_sanitized), status
)
self._report_status(self.status_topic, status)

def _on_connect(
self, _client: Any, _userdata: Any, _flags: Any, rc: int, _props: Any = None
self,
_client: Any,
_userdata: Any,
_flags: Any,
reason_code: Any,
_props: Any = None,
) -> None:
"""Handle the connection return.

Expand All @@ -221,28 +243,64 @@ def _on_connect(
The userdata (unused)
_flags
The flags (unused)
rc
The return code
reason_code
The reason code
_props
The props (unused)

"""
if rc == 0:
if reason_code == 0:
main_logger.info("Connected to MQTT broker.")
self.connected = True
return
elif rc == 1:
main_logger.error("Connection refused – incorrect protocol version")
elif rc == 2:
main_logger.error("Connection refused – invalid client identifier")
elif rc == 3:
main_logger.error("Connection refused – server unavailable")
elif rc == 4:
main_logger.error("Connection refused – bad username or password")
elif rc == 5:
main_logger.error("Connection refused – not authorised")
self._report_all_statuses(True)
self.first_connection_event.set()
else:
main_logger.error("Connection refused : %s", reason_code.getName())

def _on_connect_fail(self, _client: Any, _userdata: Any) -> None:
"""Handle the connection failure.

Parameters
----------
_client
The client id (unused)
_userdata
The userdata (unused)

"""
main_logger.error("Connect failed")

def _on_disconnect(
self,
_client: Any,
_userdata: Any,
_flags: Any,
reason_code: Any,
_props: Any = None,
) -> None:
"""Handle the disconnection return.

Parameters
----------
_client
The client id (unused)
_userdata
The userdata (unused)
_flags
The flags (unused)
reason_code
The reason code
_props
The props (unused)

"""
if reason_code == 0:
main_logger.warning("Disconnected from MQTT broker.")
else:
main_logger.error("Connection refused")
main_logger.error(
"Disconnected : ReasonCode %d, %s",
reason_code.value,
reason_code.getName(),
)

def _mqtt_send(self, topic: str, payload: str, retain: bool = False) -> None:
"""Send a mqtt payload to for a topic.
Expand Down Expand Up @@ -286,17 +344,19 @@ def _device_definition(self) -> LinuxDeviceEntry:
"identifiers": f"{sanitize(self.cfg['linux2mqtt_hostname'])}_{self.cfg['mqtt_topic_prefix']}",
"name": f"{self.cfg['linux2mqtt_hostname']} {self.cfg['mqtt_topic_prefix'].title()}",
"model": f"{platform.system()} {platform.machine()}",
"hw_version": f"{platform.release()}",
"sw_version": f"linux2mqtt {self.version}",
}

def _report_status(self, status_topic: str, status: bool) -> None:
"""Report the status on mqtt of linux2mqtt.
"""Report a status on mqtt.

Parameters
----------
status_topic
The status topic for linux2mqtt
The status topic
status
The status to set on the status topic for linux2mqtt
The status to set on the status topic

"""
self._mqtt_send(status_topic, "online" if status else "offline", retain=True)
Expand Down Expand Up @@ -324,11 +384,7 @@ def _cleanup(self) -> None:
"""Cleanup the linux2mqtt."""
main_logger.warning("Shutting down gracefully.")
try:
for metric in self.metrics:
self._report_status(
self.availability_topic.format(metric.name_sanitized), False
)
self._mqtt_send(self.status_topic, "offline", retain=True)
self._report_all_statuses(False)
self.mqtt.loop_stop()
self.mqtt.disconnect()
except Linux2MqttConnectionException as ex:
Expand All @@ -348,6 +404,7 @@ def _create_discovery_topics(self) -> None:
for metric in self.metrics:
discovery_entries = metric.get_discovery(
self.state_topic,
self.status_topic,
self.availability_topic,
self._device_definition(),
self.cfg["homeassistant_disable_attributes"],
Expand Down Expand Up @@ -429,11 +486,11 @@ def loop_busy(self, raise_known_exceptions: bool = False) -> None:
If anything with the mqtt connection goes wrong

"""
while not self.connected:
while not self.first_connection_event.wait(5):
main_logger.debug("Waiting for connection.")
time.sleep(1)

self._create_discovery_topics()
self._mqtt_send(self.version_topic, self.version, retain=True)
while True:
try:
for metric in self.metrics:
Expand All @@ -455,6 +512,55 @@ def loop_busy(self, raise_known_exceptions: bool = False) -> None:
x += 1


def configure_logger(args: argparse.Namespace) -> None:
"""Configure main logger.

Parameters
----------
args
Parsed program arguments

"""
if args.verbosity >= 5:
main_logger.setLevel(logging.DEBUG)
elif args.verbosity == 4:
main_logger.setLevel(logging.INFO)
elif args.verbosity == 3:
main_logger.setLevel(logging.WARNING)
elif args.verbosity == 2:
main_logger.setLevel(logging.ERROR)
elif args.verbosity == 1:
main_logger.setLevel(logging.CRITICAL)

# Configure logger
main_logger.propagate = False

log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
formatter = logging.Formatter(log_format)

console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
main_logger.addHandler(console_handler)

if args.logdir:
try:
logdir = Path(args.logdir)
absolute_logdir = logdir.resolve() if not logdir.is_absolute() else logdir
absolute_logdir.mkdir(parents=True, exist_ok=True)
log_file = path.join(absolute_logdir, "linux2mqtt.log")
file_handler = RotatingFileHandler(
log_file, maxBytes=1_000_000, backupCount=5
)
file_handler.setFormatter(formatter)
main_logger.addHandler(file_handler)
except Exception as ex:
main_logger.warning(
"Failed to initialize logging to directory %s : %s",
args.logdir,
str(ex),
)


def main() -> None:
"""Run main entry for the linux2mqtt executable.

Expand Down Expand Up @@ -600,6 +706,11 @@ def main() -> None:
metavar="INTERVAL",
choices=range(MIN_PACKAGE_INTERVAL, MAX_PACKAGE_INTERVAL),
)
parser.add_argument(
"--logdir",
default=None,
help="Enables logging to specified directory (default: None)",
)

try:
args = parser.parse_args()
Expand All @@ -610,16 +721,7 @@ def main() -> None:
"Cannot start due to bad config data type"
) from ex

if args.verbosity >= 5:
main_logger.setLevel(logging.DEBUG)
elif args.verbosity == 4:
main_logger.setLevel(logging.INFO)
elif args.verbosity == 3:
main_logger.setLevel(logging.WARNING)
elif args.verbosity == 2:
main_logger.setLevel(logging.ERROR)
elif args.verbosity == 1:
main_logger.setLevel(logging.CRITICAL)
configure_logger(args)

log_level = ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG", "DEBUG"][
args.verbosity
Expand Down
Loading
Loading