From 4f198dbbb590fbc1f497deb437073fa1b3fa012a Mon Sep 17 00:00:00 2001 From: Bimal Shah Date: Thu, 7 Aug 2025 19:01:05 -0400 Subject: [PATCH 01/20] Added harddrive metric thread --- linux2mqtt/metrics.py | 50 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/linux2mqtt/metrics.py b/linux2mqtt/metrics.py index 67fc06c..bd90ccf 100755 --- a/linux2mqtt/metrics.py +++ b/linux2mqtt/metrics.py @@ -25,6 +25,7 @@ ) from .helpers import sanitize from .package_manager import PackageManager, get_package_manager +from .harddrive import HardDrive from .type_definitions import LinuxDeviceEntry, LinuxEntry, SensorType metric_logger = logging.getLogger("metrics") @@ -971,3 +972,52 @@ def poll(self, result_queue: Queue[BaseMetric]) -> bool: th.daemon = True th.start() return True # Expect a deferred result + +class HardDriveMetricThread(BaseMetricThread): + """CPU metric thread.""" + + def __init__( + self, result_queue: Queue[BaseMetric], metric: BaseMetric, harddrive: HardDrive + ): + """Initialize the cpu thread. + + Parameters + ---------- + result_queue + The queue to put the metric into once the data is gathered + metric + The cpu metric to gather data for + interval + The interval to gather data over + + """ + threading.Thread.__init__(self) + self.result_queue = result_queue + self.metric = metric + self.harddrive = harddrive + + def run(self) -> None: + """Run the cpu thread. Once data is gathered, it is put into the queue and the thread exits. + + Raises + ------ + Linux2MqttMetricsException + cpu information could not be gathered or prepared for publishing + + """ + try: + # + self.harddrive.get_status() + self.metric.polled_result = { + "status": "", #Healthy, Prefail, Failed + "name":"", # These might just come from the self.attributes + "temperature":"", + "size":"", + + **jsons.dump(self.harddrive.attributes), # type: ignore[unused-ignore] + } + self.result_queue.put(self.metric) + except Exception as ex: + raise Linux2MqttMetricsException( + "Could not gather and publish hard drive data" + ) from ex \ No newline at end of file From 6c6cef370b770bcc82af145653bf37ea2cf891f3 Mon Sep 17 00:00:00 2001 From: Bimal Shah Date: Thu, 7 Aug 2025 19:16:51 -0400 Subject: [PATCH 02/20] Added hard drive metrics class --- linux2mqtt/metrics.py | 79 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 76 insertions(+), 3 deletions(-) diff --git a/linux2mqtt/metrics.py b/linux2mqtt/metrics.py index bd90ccf..23a9bef 100755 --- a/linux2mqtt/metrics.py +++ b/linux2mqtt/metrics.py @@ -25,7 +25,7 @@ ) from .helpers import sanitize from .package_manager import PackageManager, get_package_manager -from .harddrive import HardDrive +from .harddrive import HardDrive, get_hard_drive from .type_definitions import LinuxDeviceEntry, LinuxEntry, SensorType metric_logger = logging.getLogger("metrics") @@ -979,7 +979,7 @@ class HardDriveMetricThread(BaseMetricThread): def __init__( self, result_queue: Queue[BaseMetric], metric: BaseMetric, harddrive: HardDrive ): - """Initialize the cpu thread. + """Initialize the HardDrive thread. Parameters ---------- @@ -1020,4 +1020,77 @@ def run(self) -> None: except Exception as ex: raise Linux2MqttMetricsException( "Could not gather and publish hard drive data" - ) from ex \ No newline at end of file + ) from ex + +class HardDriveMetrics(BaseMetric): + """Hard Drive metric.""" + + icon = "mdi:harddisk" + device_class = "" # TODO See if I can have categories for this + unit_of_measurement = "" + state_field = "current" # TODO This is the entry from polled results that is set as the state + + _name_template = "Hard Drive (ID:{})" + _device: str + _thermal_zone: str + + def __init__(self, device: str): + """Initialize the hard drive metric. + + Parameters + ---------- + device + The device + + Raises + ------ + Linux2MqttConfigException + Bad config + + """ + super().__init__() + + try: + self.harddrive = get_hard_drive(device_name=device) + except NoPackageManagerFound as ex: + raise Linux2MqttException( + "Failed to find a suitable hard drive type. Currently supported are: Hard Disk and NVME" + ) from ex + + + + def poll(self, result_queue: Queue[Self]) -> bool: + """Poll new data for the hard drive metric. + + Parameters + ---------- + result_queue + The queue where to post new data once gathered + + Returns + ------- + bool = False + True as the data is gathered lazily + + Raises + ------ + Linux2MqttException + General exception + + """ + try: + assert result_queue + except ReferenceError as e: + raise Linux2MqttException( + "Cannot start hard drive metric due to missing result_queue" + ) from e + self.result_queue = result_queue + th = HardDriveMetricThread( + result_queue=result_queue, + metric=self, + harddrive=self.harddrive + + ) + th.daemon = True + th.start() + return True # Expect a deferred result \ No newline at end of file From baee8e77226db3730f2bd583f70dff9759f08304 Mon Sep 17 00:00:00 2001 From: Bimal Shah Date: Thu, 7 Aug 2025 21:20:15 -0400 Subject: [PATCH 03/20] Added hard drive exceptions --- linux2mqtt/exceptions.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/linux2mqtt/exceptions.py b/linux2mqtt/exceptions.py index ae91300..96b7288 100644 --- a/linux2mqtt/exceptions.py +++ b/linux2mqtt/exceptions.py @@ -23,3 +23,6 @@ class NoPackageManagerFound(Linux2MqttException): class PackageManagerException(Linux2MqttException): """Generic package manager exception occurred.""" + +class HardDriveException(Linux2MqttException): + """Generic Hard Drive exception occured.""" From 93e25feed539329913d211d064d0d3bcf962a3a1 Mon Sep 17 00:00:00 2001 From: Bimal Shah Date: Thu, 7 Aug 2025 21:28:32 -0400 Subject: [PATCH 04/20] Setting up the hard drive classes --- linux2mqtt/harddrive.py | 79 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 linux2mqtt/harddrive.py diff --git a/linux2mqtt/harddrive.py b/linux2mqtt/harddrive.py new file mode 100644 index 0000000..3249acb --- /dev/null +++ b/linux2mqtt/harddrive.py @@ -0,0 +1,79 @@ +"""Hard drives""" + +from subprocess import DEVNULL, PIPE, STDOUT, Popen, run +from time import time +import re + +from .exceptions import HardDriveException + + +class HardDrive: + """Base class for all harddrives to implement""" + #parameters + + def __init__(self, device_id: str): + """Initialize the hard drive metric. + + Parameters + ---------- + device + The device + thermal_zone + The thermal zone + + Raises + ------ + Linux2MqttConfigException + Bad config + + """ + self._device = device_id + # self._name = self._name = self._name_template.format(device_) Use the device name from smartctl for the device name + + pass + + def _get_attributes(): + pass + +class HardDisk(HardDrive): + pass + +class NVME(HardDrive): + pass + + +# Create a class for Spinning disks and one for NVME +# In the argparse, have the code create a metric for each of the harddrives found in potential disks +# The metric can be HDD / SSD depending on the regex match +# Create a thread for the actual execution of the command as it relies on running subprocess command + + + + + +def get_hard_drive(device_name:str) -> HardDrive: + """Determine the hard drive type. + + Returns + ------- + HardDrive + The specific hard drive type for drive id + + """ + + ata_regex = "^ata.*(? Date: Thu, 7 Aug 2025 22:00:48 -0400 Subject: [PATCH 05/20] Atrributes are gathered from the command --- linux2mqtt/harddrive.py | 36 +++++++++++++++++++++--------------- linux2mqtt/metrics.py | 21 ++++++++++++--------- 2 files changed, 33 insertions(+), 24 deletions(-) diff --git a/linux2mqtt/harddrive.py b/linux2mqtt/harddrive.py index 3249acb..8449440 100644 --- a/linux2mqtt/harddrive.py +++ b/linux2mqtt/harddrive.py @@ -1,39 +1,45 @@ """Hard drives""" +import json +import shlex from subprocess import DEVNULL, PIPE, STDOUT, Popen, run +import subprocess from time import time import re -from .exceptions import HardDriveException +from .exceptions import HardDriveException, Linux2MqttException class HardDrive: """Base class for all harddrives to implement""" #parameters - + _attributes = None + device_id: str + def __init__(self, device_id: str): """Initialize the hard drive metric. Parameters ---------- - device - The device - thermal_zone - The thermal zone - - Raises - ------ - Linux2MqttConfigException - Bad config - + device_id + The device id from /dev/disk/by-id/ + """ - self._device = device_id + self.device_id = device_id # self._name = self._name = self._name_template.format(device_) Use the device name from smartctl for the device name pass - def _get_attributes(): - pass + def _get_attributes(self): + command = shlex.split(f"/usr/sbin/smartctl --info --all --json --nocheck standby /dev/disk/by-id/{self.device_id}") + output = subprocess.run(command, capture_output=True) + + raw_json_data = json.loads(output.stdout) + self._attributes = raw_json_data + + def parse_attributes(self): + """Hard Drive specific parse function depending on results from smartctl.""" + raise Linux2MqttException from NotImplementedError class HardDisk(HardDrive): pass diff --git a/linux2mqtt/metrics.py b/linux2mqtt/metrics.py index 23a9bef..4e80ccd 100755 --- a/linux2mqtt/metrics.py +++ b/linux2mqtt/metrics.py @@ -18,6 +18,7 @@ MIN_NET_INTERVAL, ) from .exceptions import ( + HardDriveException, Linux2MqttConfigException, Linux2MqttException, Linux2MqttMetricsException, @@ -974,7 +975,7 @@ def poll(self, result_queue: Queue[BaseMetric]) -> bool: return True # Expect a deferred result class HardDriveMetricThread(BaseMetricThread): - """CPU metric thread.""" + """Hard Drive metric thread.""" def __init__( self, result_queue: Queue[BaseMetric], metric: BaseMetric, harddrive: HardDrive @@ -986,9 +987,9 @@ def __init__( result_queue The queue to put the metric into once the data is gathered metric - The cpu metric to gather data for - interval - The interval to gather data over + The hard drive metric to gather data for + harddrive + The type of hard drive to gather data over """ threading.Thread.__init__(self) @@ -997,18 +998,19 @@ def __init__( self.harddrive = harddrive def run(self) -> None: - """Run the cpu thread. Once data is gathered, it is put into the queue and the thread exits. + """Run the hard drive thread. Once data is gathered, it is put into the queue and the thread exits. Raises ------ Linux2MqttMetricsException - cpu information could not be gathered or prepared for publishing + hard drive information could not be gathered or prepared for publishing """ try: # self.harddrive.get_status() self.metric.polled_result = { + # TODO Fill out the attributes metric for Hard Drive and SSD "status": "", #Healthy, Prefail, Failed "name":"", # These might just come from the self.attributes "temperature":"", @@ -1016,6 +1018,7 @@ def run(self) -> None: **jsons.dump(self.harddrive.attributes), # type: ignore[unused-ignore] } + self.metric._name = self.harddrive.attributes['model_name'] self.result_queue.put(self.metric) except Exception as ex: raise Linux2MqttMetricsException( @@ -1028,7 +1031,7 @@ class HardDriveMetrics(BaseMetric): icon = "mdi:harddisk" device_class = "" # TODO See if I can have categories for this unit_of_measurement = "" - state_field = "current" # TODO This is the entry from polled results that is set as the state + state_field = "status" _name_template = "Hard Drive (ID:{})" _device: str @@ -1044,7 +1047,7 @@ def __init__(self, device: str): Raises ------ - Linux2MqttConfigException + Linux2MqttException Bad config """ @@ -1052,7 +1055,7 @@ def __init__(self, device: str): try: self.harddrive = get_hard_drive(device_name=device) - except NoPackageManagerFound as ex: + except HardDriveException as ex: raise Linux2MqttException( "Failed to find a suitable hard drive type. Currently supported are: Hard Disk and NVME" ) from ex From 9ceb19d847fa57ebe9ee194ad4fc69e702184c09 Mon Sep 17 00:00:00 2001 From: Bimal Shah Date: Fri, 8 Aug 2025 01:33:33 -0400 Subject: [PATCH 06/20] Added hard drive status enum Updated subprocess to use popen included Hard drive functions, as well as status scoring for sata and nvme --- linux2mqtt/harddrive.py | 157 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 148 insertions(+), 9 deletions(-) diff --git a/linux2mqtt/harddrive.py b/linux2mqtt/harddrive.py index 8449440..909e8e9 100644 --- a/linux2mqtt/harddrive.py +++ b/linux2mqtt/harddrive.py @@ -1,5 +1,6 @@ """Hard drives""" +from enum import Enum import json import shlex from subprocess import DEVNULL, PIPE, STDOUT, Popen, run @@ -9,6 +10,12 @@ from .exceptions import HardDriveException, Linux2MqttException +class HARDDRIVESTATUS(Enum): + HEALTHY = 'healthy' + GOOD = 'good' + WARNING = 'warning' + FAILING = 'failing' + class HardDrive: """Base class for all harddrives to implement""" @@ -32,20 +39,154 @@ def __init__(self, device_id: str): def _get_attributes(self): command = shlex.split(f"/usr/sbin/smartctl --info --all --json --nocheck standby /dev/disk/by-id/{self.device_id}") - output = subprocess.run(command, capture_output=True) - - raw_json_data = json.loads(output.stdout) + with Popen( + command, + stdout=PIPE, + stderr=DEVNULL, + text=True, + ) as proc: + stdout, stderr = proc.communicate(timeout=30) + + if proc.returncode != 0: + raise HardDriveException( + f"Something went wrong with smartctl: {proc.returncode}: '{stderr}'" + ) + + raw_json_data = json.loads(stdout) + + # output = subprocess.run(command, capture_output=True) + + # raw_json_data = json.loads(output.stdout) self._attributes = raw_json_data def parse_attributes(self): """Hard Drive specific parse function depending on results from smartctl.""" raise Linux2MqttException from NotImplementedError + + + def get_status_score(self): + """Hard Drive specific score function depending on results from smartctl.""" + raise Linux2MqttException from NotImplementedError + + + +class SataDrive(HardDrive): + + def parse_attributes(self): + ata_smart_attributes = [("Reallocated Sector Count", 5), ("Command Timeout", 38), ("Reported Uncorrectable Errors",187), + ("Current Pending Sector", 197), ("Offline Uncorrectable", 198), ("UDMA CRC Error Count", 199), + ] + + self.attributes["Model Name"] = self._attributes['model_name'] + self.attributes["Device"] = self._attributes['device']['name'] + self.attributes["Size TB"] = self._attributes['user_capacity']['bytes']/1000000000000 + self.attributes["Temperature"] = self._attributes['temperature']['current'] + self.attributes["Smart status"] = 'Healthy' if self._attributes['smart_status']['passed'] else 'Failed' + self.attributes["Power On Time"] = self._attributes['power_on_time']['hours'] + self.attributes["Power Cycle Count"] = self._attributes['power_cycle_count'] + + new_data = {item['id']: item for item in self._attributes['ata_smart_attributes']['table']} + for name, key in ata_smart_attributes: + tmp = new_data[key]['raw']['value'] if new_data.get(key) else None + if tmp: + self.attributes[name] = tmp + + self.attributes['status'] = self.get_status_score() + + + def get_status_score(self): + score = 0 + score += self.attributes.get('Reallocated Sector Count',0) * 2 + if self.attributes.get('Reallocated Sector Count',0) > 50: + score += 50 + + score += self.attributes.get('Current Pending Sector',0) * 3 + if self.attributes.get('Current Pending Sector',0) > 10: + score += 30 + + score += self.attributes.get('Offline Uncorrectable',0) * 3 + score += self.attributes.get('Reported Uncorrectable Errors',0) * 2 + score += self.attributes.get('Command Timeout',0) * 1.5 + score += min(self.attributes.get('UDMA CRC Error Count',0), 10) + + # SMART CTL isnt consistent enough to come up with a percentage used for SSDs.... + # if 'percent_used' in attributes: + # if attributes['percent_used'] > 90: + # score += 30 + # elif attributes['percent_used'] > 80: + # score += 10 + + if score <= 10: + return HARDDRIVESTATUS.HEALTHY + elif score <= 20: + return HARDDRIVESTATUS.GOOD + elif score <= 50: + return HARDDRIVESTATUS.WARNING + else: + return HARDDRIVESTATUS.FAILING -class HardDisk(HardDrive): - pass class NVME(HardDrive): - pass + def parse_attributes(self): + nvme_smart_attributes = ['critical_warning', 'percentage_used', 'power_on_hours', 'power_cycles', 'media_errors', 'num_err_log_entries', + 'critical_comp_time', 'warning_temp_time', 'available_spare', 'available_spare_threshold'] + + self.attributes["Model Name"] = self._attributes['model_name'] + self.attributes["Device"] = self._attributes['device']['name'] + self.attributes["Size TB"] = self._attributes['user_capacity']['bytes']/1000000000000 + self.attributes["Temperature"] = self._attributes['temperature']['current'] + self.attributes["Smart status"] = 'Healthy' if self._attributes['smart_status']['passed'] else 'Failed' + + for key in nvme_smart_attributes: + tmp = self._attributes['nvme_smart_health_information_log'].get(key) + if tmp: + self.attributes[key] = tmp + + self.attributes['status'] = self.get_status_score() + + + + def get_status_score(self): + score = 0 + + # Critical warnings (bitmask) + if self.attributes.get('critical_warning') != 0: + score += 100 # Any critical flag = high risk + + # NAND wear + if self.attributes.get('percent_used') > 90: + score += 50 + elif self.attributes.get('percent_used') > 80: + score += 20 + elif self.attributes.get('percent_used') > 70: + score += 10 + + # Media/data errors + score += self.attributes.get('media_errors') * 5 + + # Error log entries + score += min(self.attributes.get('num_error_log_entries'), 50) # cap at 50 + + # Temperature issues + if self.attributes.get('critical_temp_time') > 0: + score += 30 + elif self.attributes.get('warning_temp_time') > 0: + score += 10 + + # Available spare + if self.attributes.get('available_spare') < self.attributes.get('available_spare_threshold'): + score += 30 + + # Classification + if score <= 10: + return HARDDRIVESTATUS.HEALTHY + elif score <= 20: + return HARDDRIVESTATUS.GOOD + elif score <= 50: + return HARDDRIVESTATUS.WARNING + else: + return HARDDRIVESTATUS.FAILING + # Create a class for Spinning disks and one for NVME @@ -53,8 +194,6 @@ class NVME(HardDrive): # The metric can be HDD / SSD depending on the regex match # Create a thread for the actual execution of the command as it relies on running subprocess command - - def get_hard_drive(device_name:str) -> HardDrive: @@ -75,7 +214,7 @@ def get_hard_drive(device_name:str) -> HardDrive: r2 = re.compile(nvme_regex) if r1.match(device_name): - return HardDisk(device_name) + return SataDrive(device_name) elif r2.match(device_name): return NVME(device_name) else: From 1ea860ac08d62f279117476ef800ec896cc41014 Mon Sep 17 00:00:00 2001 From: Bimal Shah Date: Sun, 10 Aug 2025 21:37:52 -0400 Subject: [PATCH 07/20] Added harddrives to argparse Added new exception for paths not used. --- linux2mqtt/exceptions.py | 3 +++ linux2mqtt/harddrive.py | 5 ++--- linux2mqtt/linux2mqtt.py | 21 ++++++++++++++++++++- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/linux2mqtt/exceptions.py b/linux2mqtt/exceptions.py index 96b7288..305bfaa 100644 --- a/linux2mqtt/exceptions.py +++ b/linux2mqtt/exceptions.py @@ -26,3 +26,6 @@ class PackageManagerException(Linux2MqttException): class HardDriveException(Linux2MqttException): """Generic Hard Drive exception occured.""" + +class HardDriveIDException(Linux2MqttException): + """Generic Hard Drive exception occured.""" \ No newline at end of file diff --git a/linux2mqtt/harddrive.py b/linux2mqtt/harddrive.py index 909e8e9..7f1051e 100644 --- a/linux2mqtt/harddrive.py +++ b/linux2mqtt/harddrive.py @@ -8,7 +8,7 @@ from time import time import re -from .exceptions import HardDriveException, Linux2MqttException +from .exceptions import HardDriveException, HardDriveIDException, Linux2MqttException class HARDDRIVESTATUS(Enum): HEALTHY = 'healthy' @@ -218,7 +218,6 @@ def get_hard_drive(device_name:str) -> HardDrive: elif r2.match(device_name): return NVME(device_name) else: - return None - raise HardDriveException + raise HardDriveIDException("Harddrive ID not supported") diff --git a/linux2mqtt/linux2mqtt.py b/linux2mqtt/linux2mqtt.py index 6bf48db..b3e1d95 100755 --- a/linux2mqtt/linux2mqtt.py +++ b/linux2mqtt/linux2mqtt.py @@ -5,6 +5,7 @@ import json import logging from os import geteuid +import os import platform from queue import Empty, Queue import signal @@ -13,6 +14,7 @@ import time from typing import Any +from linux2mqtt.harddrive import get_hard_drive import paho.mqtt.client import psutil @@ -39,7 +41,7 @@ MQTT_QOS_DEFAULT, MQTT_TIMEOUT_DEFAULT, ) -from .exceptions import Linux2MqttConfigException, Linux2MqttConnectionException +from .exceptions import HardDriveIDException, Linux2MqttConfigException, Linux2MqttConnectionException from .helpers import clean_for_discovery, sanitize from .metrics import ( BaseMetric, @@ -51,6 +53,7 @@ PackageUpdateMetrics, TempMetrics, VirtualMemoryMetrics, + HardDriveMetrics, ) from .type_definitions import Linux2MqttConfig, LinuxDeviceEntry @@ -585,6 +588,11 @@ def main() -> None: metavar="INTERVAL", choices=range(MIN_PACKAGE_INTERVAL, MAX_PACKAGE_INTERVAL), ) + parser.add_argument( + "--harddrives", + help="Publish hard drive stats if available", + action="store_true", + ) try: args = parser.parse_args() @@ -677,6 +685,16 @@ def main() -> None: ) stats.add_metric(package_updates) + if args.harddrives: + for drive in os.listdir("/dev/disk/by-id/"): + try: + harddrive = HardDriveMetrics(drive) + # harddrive = get_hard_drive(drive) + if harddrive: + stats.add_metric(harddrive) + except HardDriveIDException: + pass + if not ( args.vm or args.connections @@ -686,6 +704,7 @@ def main() -> None: or args.temp or args.fan or args.packages + or args.harddrives ): main_logger.warning("No metrics specified. Nothing will be published.") From cfaa9afdc07e77ba3084e5eff0f250bc41d728b2 Mon Sep 17 00:00:00 2001 From: Bimal Shah Date: Mon, 11 Aug 2025 07:19:22 -0400 Subject: [PATCH 08/20] Bug fixes for function names, and order that they are called. --- linux2mqtt/harddrive.py | 5 ++++- linux2mqtt/linux2mqtt.py | 2 +- linux2mqtt/metrics.py | 11 +++-------- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/linux2mqtt/harddrive.py b/linux2mqtt/harddrive.py index 7f1051e..217fe4d 100644 --- a/linux2mqtt/harddrive.py +++ b/linux2mqtt/harddrive.py @@ -22,7 +22,8 @@ class HardDrive: #parameters _attributes = None device_id: str - + attributes = dict() + def __init__(self, device_id: str): """Initialize the hard drive metric. @@ -71,8 +72,10 @@ def get_status_score(self): class SataDrive(HardDrive): + def parse_attributes(self): + self._get_attributes() ata_smart_attributes = [("Reallocated Sector Count", 5), ("Command Timeout", 38), ("Reported Uncorrectable Errors",187), ("Current Pending Sector", 197), ("Offline Uncorrectable", 198), ("UDMA CRC Error Count", 199), ] diff --git a/linux2mqtt/linux2mqtt.py b/linux2mqtt/linux2mqtt.py index b3e1d95..276f784 100755 --- a/linux2mqtt/linux2mqtt.py +++ b/linux2mqtt/linux2mqtt.py @@ -14,7 +14,6 @@ import time from typing import Any -from linux2mqtt.harddrive import get_hard_drive import paho.mqtt.client import psutil @@ -57,6 +56,7 @@ ) from .type_definitions import Linux2MqttConfig, LinuxDeviceEntry + logging.basicConfig(format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") main_logger = logging.getLogger("linux2mqtt") diff --git a/linux2mqtt/metrics.py b/linux2mqtt/metrics.py index 4e80ccd..936e5c1 100755 --- a/linux2mqtt/metrics.py +++ b/linux2mqtt/metrics.py @@ -1008,17 +1008,11 @@ def run(self) -> None: """ try: # - self.harddrive.get_status() + self.harddrive.parse_attributes() self.metric.polled_result = { - # TODO Fill out the attributes metric for Hard Drive and SSD - "status": "", #Healthy, Prefail, Failed - "name":"", # These might just come from the self.attributes - "temperature":"", - "size":"", - **jsons.dump(self.harddrive.attributes), # type: ignore[unused-ignore] } - self.metric._name = self.harddrive.attributes['model_name'] + # self.metric._name = self.harddrive.attributes['model_name'] self.result_queue.put(self.metric) except Exception as ex: raise Linux2MqttMetricsException( @@ -1055,6 +1049,7 @@ def __init__(self, device: str): try: self.harddrive = get_hard_drive(device_name=device) + self._name = self._name_template.format(device) except HardDriveException as ex: raise Linux2MqttException( "Failed to find a suitable hard drive type. Currently supported are: Hard Disk and NVME" From 2222e0e2156cc05d6b004524af27e30966defcf6 Mon Sep 17 00:00:00 2001 From: Bimal Shah Date: Mon, 11 Aug 2025 21:51:24 -0400 Subject: [PATCH 09/20] Fixed packages bug Removed enum harddrive status Fixed attributes issue if 0 --- linux2mqtt/harddrive.py | 50 ++++++++++++++++++---------------------- linux2mqtt/linux2mqtt.py | 3 ++- linux2mqtt/metrics.py | 4 ++-- 3 files changed, 27 insertions(+), 30 deletions(-) diff --git a/linux2mqtt/harddrive.py b/linux2mqtt/harddrive.py index 217fe4d..ac8db5d 100644 --- a/linux2mqtt/harddrive.py +++ b/linux2mqtt/harddrive.py @@ -1,29 +1,23 @@ """Hard drives""" -from enum import Enum + import json import shlex from subprocess import DEVNULL, PIPE, STDOUT, Popen, run -import subprocess +# import subprocess from time import time import re from .exceptions import HardDriveException, HardDriveIDException, Linux2MqttException -class HARDDRIVESTATUS(Enum): - HEALTHY = 'healthy' - GOOD = 'good' - WARNING = 'warning' - FAILING = 'failing' - class HardDrive: """Base class for all harddrives to implement""" #parameters _attributes = None device_id: str - attributes = dict() - + attributes: dict + def __init__(self, device_id: str): """Initialize the hard drive metric. @@ -75,6 +69,7 @@ class SataDrive(HardDrive): def parse_attributes(self): + self.attributes = dict() self._get_attributes() ata_smart_attributes = [("Reallocated Sector Count", 5), ("Command Timeout", 38), ("Reported Uncorrectable Errors",187), ("Current Pending Sector", 197), ("Offline Uncorrectable", 198), ("UDMA CRC Error Count", 199), @@ -91,7 +86,7 @@ def parse_attributes(self): new_data = {item['id']: item for item in self._attributes['ata_smart_attributes']['table']} for name, key in ata_smart_attributes: tmp = new_data[key]['raw']['value'] if new_data.get(key) else None - if tmp: + if tmp is not None: self.attributes[name] = tmp self.attributes['status'] = self.get_status_score() @@ -120,17 +115,18 @@ def get_status_score(self): # score += 10 if score <= 10: - return HARDDRIVESTATUS.HEALTHY + return "HEALTHY" elif score <= 20: - return HARDDRIVESTATUS.GOOD + return "GOOD" elif score <= 50: - return HARDDRIVESTATUS.WARNING + return "WARNING" else: - return HARDDRIVESTATUS.FAILING + return "FAILING" class NVME(HardDrive): def parse_attributes(self): + self._get_attributes() nvme_smart_attributes = ['critical_warning', 'percentage_used', 'power_on_hours', 'power_cycles', 'media_errors', 'num_err_log_entries', 'critical_comp_time', 'warning_temp_time', 'available_spare', 'available_spare_threshold'] @@ -142,7 +138,7 @@ def parse_attributes(self): for key in nvme_smart_attributes: tmp = self._attributes['nvme_smart_health_information_log'].get(key) - if tmp: + if tmp is not None: self.attributes[key] = tmp self.attributes['status'] = self.get_status_score() @@ -157,38 +153,38 @@ def get_status_score(self): score += 100 # Any critical flag = high risk # NAND wear - if self.attributes.get('percent_used') > 90: + if self.attributes.get('percent_used',0) > 90: score += 50 - elif self.attributes.get('percent_used') > 80: + elif self.attributes.get('percent_used',0) > 80: score += 20 - elif self.attributes.get('percent_used') > 70: + elif self.attributes.get('percent_used',0) > 70: score += 10 # Media/data errors - score += self.attributes.get('media_errors') * 5 + score += self.attributes.get('media_errors',0) * 5 # Error log entries score += min(self.attributes.get('num_error_log_entries'), 50) # cap at 50 # Temperature issues - if self.attributes.get('critical_temp_time') > 0: + if self.attributes.get('critical_temp_time',0) > 0: score += 30 - elif self.attributes.get('warning_temp_time') > 0: + elif self.attributes.get('warning_temp_time',0) > 0: score += 10 # Available spare - if self.attributes.get('available_spare') < self.attributes.get('available_spare_threshold'): + if self.attributes.get('available_spare',0) < self.attributes.get('available_spare_threshold',0): score += 30 # Classification if score <= 10: - return HARDDRIVESTATUS.HEALTHY + return "HEALTHY" elif score <= 20: - return HARDDRIVESTATUS.GOOD + return "GOOD" elif score <= 50: - return HARDDRIVESTATUS.WARNING + return "WARNING" else: - return HARDDRIVESTATUS.FAILING + return "FAILING" diff --git a/linux2mqtt/linux2mqtt.py b/linux2mqtt/linux2mqtt.py index 276f784..80ce05e 100755 --- a/linux2mqtt/linux2mqtt.py +++ b/linux2mqtt/linux2mqtt.py @@ -584,7 +584,8 @@ def main() -> None: help="Publish package updates if available", type=int, nargs="?", - default=DEFAULT_PACKAGE_INTERVAL, + const=DEFAULT_PACKAGE_INTERVAL, + default=None, metavar="INTERVAL", choices=range(MIN_PACKAGE_INTERVAL, MAX_PACKAGE_INTERVAL), ) diff --git a/linux2mqtt/metrics.py b/linux2mqtt/metrics.py index 936e5c1..4b15438 100755 --- a/linux2mqtt/metrics.py +++ b/linux2mqtt/metrics.py @@ -1010,13 +1010,13 @@ def run(self) -> None: # self.harddrive.parse_attributes() self.metric.polled_result = { - **jsons.dump(self.harddrive.attributes), # type: ignore[unused-ignore] + **self.harddrive.attributes, # type: ignore[unused-ignore] } # self.metric._name = self.harddrive.attributes['model_name'] self.result_queue.put(self.metric) except Exception as ex: raise Linux2MqttMetricsException( - "Could not gather and publish hard drive data" + f"Could not gather and publish hard drive data {self.metric._name}" ) from ex class HardDriveMetrics(BaseMetric): From a01a6863751f1ede3c5bd925343e196341a357e3 Mon Sep 17 00:00:00 2001 From: Bimal Shah Date: Mon, 11 Aug 2025 22:12:54 -0400 Subject: [PATCH 10/20] Fixed scoring bug Moved scoring function to two parts to add score as an attribute. --- linux2mqtt/harddrive.py | 60 +++++++++++++++++++---------------------- 1 file changed, 28 insertions(+), 32 deletions(-) diff --git a/linux2mqtt/harddrive.py b/linux2mqtt/harddrive.py index ac8db5d..fd978e3 100644 --- a/linux2mqtt/harddrive.py +++ b/linux2mqtt/harddrive.py @@ -17,6 +17,8 @@ class HardDrive: _attributes = None device_id: str attributes: dict + score: int + status: str def __init__(self, device_id: str): """Initialize the hard drive metric. @@ -59,10 +61,20 @@ def parse_attributes(self): raise Linux2MqttException from NotImplementedError - def get_status_score(self): + def get_score(self): """Hard Drive specific score function depending on results from smartctl.""" raise Linux2MqttException from NotImplementedError - + + def get_status(self): + # Classification + if self.score <= 10: + return "HEALTHY" + elif self.score <= 20: + return "GOOD" + elif self.score <= 50: + return "WARNING" + else: + return "FAILING" class SataDrive(HardDrive): @@ -89,10 +101,13 @@ def parse_attributes(self): if tmp is not None: self.attributes[name] = tmp - self.attributes['status'] = self.get_status_score() + self.get_score() + self.get_status() + self.attributes['score'] = self.score + self.attributes['status'] = self.status - def get_status_score(self): + def get_score(self): score = 0 score += self.attributes.get('Reallocated Sector Count',0) * 2 if self.attributes.get('Reallocated Sector Count',0) > 50: @@ -114,18 +129,12 @@ def get_status_score(self): # elif attributes['percent_used'] > 80: # score += 10 - if score <= 10: - return "HEALTHY" - elif score <= 20: - return "GOOD" - elif score <= 50: - return "WARNING" - else: - return "FAILING" + self.score = score class NVME(HardDrive): def parse_attributes(self): + self.attributes = dict() self._get_attributes() nvme_smart_attributes = ['critical_warning', 'percentage_used', 'power_on_hours', 'power_cycles', 'media_errors', 'num_err_log_entries', 'critical_comp_time', 'warning_temp_time', 'available_spare', 'available_spare_threshold'] @@ -141,11 +150,14 @@ def parse_attributes(self): if tmp is not None: self.attributes[key] = tmp - self.attributes['status'] = self.get_status_score() + self.get_score() + self.get_status() + self.attributes['score'] = self.score + self.attributes['status'] = self.status - def get_status_score(self): + def get_score(self): score = 0 # Critical warnings (bitmask) @@ -164,7 +176,7 @@ def get_status_score(self): score += self.attributes.get('media_errors',0) * 5 # Error log entries - score += min(self.attributes.get('num_error_log_entries'), 50) # cap at 50 + score += min(self.attributes.get('num_error_log_entries',0), 50) # cap at 50 # Temperature issues if self.attributes.get('critical_temp_time',0) > 0: @@ -176,25 +188,9 @@ def get_status_score(self): if self.attributes.get('available_spare',0) < self.attributes.get('available_spare_threshold',0): score += 30 - # Classification - if score <= 10: - return "HEALTHY" - elif score <= 20: - return "GOOD" - elif score <= 50: - return "WARNING" - else: - return "FAILING" + self.score = score - - -# Create a class for Spinning disks and one for NVME -# In the argparse, have the code create a metric for each of the harddrives found in potential disks -# The metric can be HDD / SSD depending on the regex match -# Create a thread for the actual execution of the command as it relies on running subprocess command - - def get_hard_drive(device_name:str) -> HardDrive: """Determine the hard drive type. From 0b21174572a24ab10b80f11f4a5728570efbb7d0 Mon Sep 17 00:00:00 2001 From: Bimal Shah Date: Mon, 11 Aug 2025 22:17:51 -0400 Subject: [PATCH 11/20] forgot to set status variable --- linux2mqtt/harddrive.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/linux2mqtt/harddrive.py b/linux2mqtt/harddrive.py index fd978e3..7c61fc1 100644 --- a/linux2mqtt/harddrive.py +++ b/linux2mqtt/harddrive.py @@ -68,13 +68,13 @@ def get_score(self): def get_status(self): # Classification if self.score <= 10: - return "HEALTHY" + self.status = "HEALTHY" elif self.score <= 20: - return "GOOD" + self.status = "GOOD" elif self.score <= 50: - return "WARNING" + self.status = "WARNING" else: - return "FAILING" + self.status = "FAILING" class SataDrive(HardDrive): @@ -190,7 +190,7 @@ def get_score(self): self.score = score - + def get_hard_drive(device_name:str) -> HardDrive: """Determine the hard drive type. From 8c946eebfbe9edb785baa23e798450053f2278f6 Mon Sep 17 00:00:00 2001 From: Bimal Shah Date: Wed, 13 Aug 2025 00:29:48 -0400 Subject: [PATCH 12/20] bugfix for smartctl return codes --- linux2mqtt/harddrive.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linux2mqtt/harddrive.py b/linux2mqtt/harddrive.py index 7c61fc1..f01834a 100644 --- a/linux2mqtt/harddrive.py +++ b/linux2mqtt/harddrive.py @@ -44,7 +44,7 @@ def _get_attributes(self): ) as proc: stdout, stderr = proc.communicate(timeout=30) - if proc.returncode != 0: + if (proc.returncode&7) != 0: raise HardDriveException( f"Something went wrong with smartctl: {proc.returncode}: '{stderr}'" ) From c51eeac50efb7c9cf90e6c852e93b86061f284dd Mon Sep 17 00:00:00 2001 From: Bimal Shah Date: Sun, 25 Jan 2026 11:09:01 -0500 Subject: [PATCH 13/20] Merge commit '3a2154e22d857fd5000ed03b426231f8da3a1d22' into feature-harddrives --- .github/FUNDING.yml | 4 + .github/dependabot.yml | 6 + .github/labels.yml | 95 ++++++ .github/release-drafter.yml | 89 ++++++ .github/workflows/documentation.yml | 6 +- .github/workflows/draft.yml | 27 ++ .github/workflows/labeler.yml | 25 ++ .github/workflows/markdownlint.yml | 6 +- .github/workflows/mypy.yaml | 6 +- .github/workflows/publish.yml | 48 ++- .github/workflows/ruff.yml | 4 +- .pre-commit-config.yaml | 4 +- .vscode/settings.json | 1 - CHANGELOG.md | 20 ++ README.md | 36 ++- docs/authors.md | 5 +- linux2mqtt/__init__.py | 2 +- linux2mqtt/const.py | 2 + linux2mqtt/helpers.py | 23 +- linux2mqtt/linux2mqtt.py | 259 ++++++++++++---- linux2mqtt/metrics.py | 450 ++++++++++++++++++++-------- linux2mqtt/type_definitions.py | 73 ++++- mkdocs.yml | 4 +- pyproject.toml | 6 +- requirements.txt | 1 - requirements_dev.txt | 2 - 26 files changed, 957 insertions(+), 247 deletions(-) create mode 100644 .github/FUNDING.yml create mode 100644 .github/labels.yml create mode 100644 .github/release-drafter.yml create mode 100644 .github/workflows/draft.yml create mode 100644 .github/workflows/labeler.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..a36cc3e --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,4 @@ +github: miaucl +patreon: miaucl +buy_me_a_coffee: miaucl +custom: https://paypal.me/sponsormiaucl \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 645c171..57a3dce 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,7 +4,13 @@ updates: directory: "/" schedule: interval: "weekly" + labels: + - ":recycle: dependencies" + - ":snake: python" - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" + labels: + - ":recycle: dependencies" + - ":clapper: github_actions" \ No newline at end of file diff --git a/.github/labels.yml b/.github/labels.yml new file mode 100644 index 0000000..eb7a632 --- /dev/null +++ b/.github/labels.yml @@ -0,0 +1,95 @@ +--- +# Labels names are important as they are used by Release Drafter to decide +# regarding where to record them in changelog or if to skip them. +# +# The repository labels will be automatically configured using this file and +# the GitHub Action https://github.com/marketplace/actions/github-labeler. +- name: ':boom: breaking change' + from_name: breaking + description: Breaking Changes + color: bfd4f2 +- name: ':ghost: bug' + from_name: bug + description: Something isn't working + color: d73a4a +- name: ':building_construction: build' + from_name: build + description: Build System and Dependencies + color: bfdadc +- name: ':construction_worker_woman: ci' + from_name: ci + description: Continuous Integration + color: 4a97d6 +- name: ':recycle: dependencies' + from_name: dependencies + description: Pull requests that update a dependency file + color: 0366d6 +- name: ':book: documentation' + from_name: documentation + description: Improvements or additions to documentation + color: 0075ca +- name: ':roll_eyes: duplicate' + from_name: duplicate + description: This issue or pull request already exists + color: cfd3d7 +- name: ':rocket: feature' + from_name: enhancement + description: New feature or request + color: a2eeef +- name: ':clapper: github_actions' + from_name: github_actions + description: Pull requests that update Github_actions code + color: '000000' +- name: ':hatching_chick: good first issue' + from_name: good first issue + description: Good for newcomers + color: 7057ff +- name: ':pray: help wanted' + from_name: help wanted + description: Extra attention is needed + color: '008672' +- name: ':no_entry_sign: invalid' + from_name: invalid + description: This doesn't seem right + color: e4e669 +- name: ':racing_car: performance' + from_name: performance + description: Performance + color: '016175' +- name: ':snake: python' + from_name: python + description: Pull requests that update Python code + color: 2b67c6 +- name: ':question: question' + from_name: question + description: Further information is requested + color: d876e3 +- name: ':sparkles: code quality' + from_name: code quality + description: Code quality improvements + color: ef67c4 +- name: ':file_cabinet: deprecation' + from_name: deprecation + description: Removals and Deprecations + color: 9ae7ea +- name: ':nail_care: style' + from_name: style + description: Style + color: c120e5 +- name: ':test_tube: testing' + from_name: testing + description: Pull request that adds tests + color: b1fc6f +- name: ':woman_shrugging: wontfix' + from_name: wontfix + description: This will not be worked on + color: ffffff +- name: ':arrow_up: bump' + description: Bumps the version for a release + color: 3C5D34 +- name: ':sparkles: enhancement' + color: CBF8DA + description: Minor feature improvement +- name: 'skip-changelog' + color: D3D3D3 + description: Omits this PR in the changelog \ No newline at end of file diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..f642059 --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,89 @@ +name-template: '$RESOLVED_VERSION' +tag-template: '$RESOLVED_VERSION' + + +categories: + - title: 'πŸ’₯ Breaking changes' + labels: + - ':boom: breaking change' + - title: 'πŸš€ New Features' + labels: + - ':rocket: feature' + - title: 'πŸ‘» Bug Fixes' + labels: + - ':ghost: bug' + - title: 'πŸ—„οΈ Deprecations' + labels: + - ':file_cabinet: deprecation' + - title: 'πŸ“ƒ Documentation' + labels: + - ':book: documentation' + - title: '🧰 Maintenance' + labels: + - ':building_construction: build' + - ':construction_worker_woman: ci' + - ':clapper: github_actions' + - title: 'πŸ”¬ Other updates' + labels: + - ':nail_care: style' + - ':test_tube: testing' + - ':racing_car: performance' + - ':sparkles: code quality' + - ':sparkles: enhancement' + - title: '🧩 Dependency Updates' + labels: + - ':recycle: dependencies' +exclude-labels: + - ':arrow_up: bump' + - 'skip-changelog' + +autolabeler: + - label: ':rocket: feature' + title: + - '/adds/i' + - '/add method/i' + - label: ':ghost: bug' + title: + - '/fix/i' + - label: ':sparkles: code quality' + title: + - '/Refactor/i' + - label: ':test_tube: testing' + files: + - 'test_*' + - 'conftest.py' + - label: ':book: documentation' + title: + - '/docs:/i' + files: + - '*.md' + - 'mkdocs.yml' + - label: ':construction_worker_woman: ci' + files: + - '.github/*' + - label: ':recycle: dependencies' + files: + - 'requirements*.txt' + - label: ':file_cabinet: deprecation' + title: + - '/Deprecate/i' + +change-template: '- $TITLE @$AUTHOR (#$NUMBER)' +change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. + + +version-resolver: + major: + labels: + - 'breaking' + minor: + labels: + - 'feature' + default: patch + +template: | + ## What's Changed + + $CHANGES + + Contributors: $CONTRIBUTORS diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 96dcefe..ab704ff 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -30,9 +30,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -43,7 +43,7 @@ jobs: - name: Build run: mkdocs build - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v4 with: path: ./site diff --git a/.github/workflows/draft.yml b/.github/workflows/draft.yml new file mode 100644 index 0000000..59637c3 --- /dev/null +++ b/.github/workflows/draft.yml @@ -0,0 +1,27 @@ +name: Release Drafter + +on: + push: + branches: + - main + - master + # pull_request event is required only for autolabeler + pull_request: + types: [opened, reopened, synchronize] + pull_request_target: + types: [opened, reopened, synchronize] + +permissions: + contents: read + +jobs: + update-draft: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + # Drafts your next Release notes as Pull Requests are merged into "main" + - uses: release-drafter/release-drafter@v6 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 0000000..2d9ccca --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,25 @@ +name: Labeler + +on: + push: + branches: + - main + - master + +permissions: + actions: read + contents: read + security-events: write + pull-requests: write + +jobs: + labeler: + runs-on: ubuntu-latest + steps: + - name: Check out the repository + uses: actions/checkout@v6 + + - name: Run Labeler + uses: crazy-max/ghaction-github-labeler@v5.3.0 + with: + skip-delete: true diff --git a/.github/workflows/markdownlint.yml b/.github/workflows/markdownlint.yml index e2e8131..7b92f33 100644 --- a/.github/workflows/markdownlint.yml +++ b/.github/workflows/markdownlint.yml @@ -2,12 +2,14 @@ name: Markdownlint on: [ push, pull_request ] +permissions: + contents: read jobs: markdownlint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: DavidAnson/markdownlint-cli2-action@v20 + - uses: actions/checkout@v6 + - uses: DavidAnson/markdownlint-cli2-action@v22 with: config: '.markdownlint.yaml' globs: | diff --git a/.github/workflows/mypy.yaml b/.github/workflows/mypy.yaml index 3092223..9bc29bd 100644 --- a/.github/workflows/mypy.yaml +++ b/.github/workflows/mypy.yaml @@ -1,4 +1,6 @@ name: Mypy +permissions: + contents: read on: [ push, pull_request ] @@ -9,8 +11,8 @@ jobs: mypy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f7949f2..b85393b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,4 +1,4 @@ -name: Publish +name: Publish Python 🐍 distribution πŸ“¦ to PyPI on: 'push' @@ -9,11 +9,13 @@ jobs: build: name: Build distribution πŸ“¦ runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -26,7 +28,7 @@ jobs: - name: Build a binary wheel and a source tarball run: python3 -m build - name: Store the distribution packages - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: python-package-distributions path: dist/ @@ -36,10 +38,12 @@ jobs: if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes needs: - build + permissions: + contents: read runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 @@ -58,19 +62,6 @@ jobs: echo "Success: Tag version $TAG_VERSION found in package." fi - - name: Check changelog entry - run: | - if [ github.ref =~ 'refs/tags/[0-9]+\\.[0-9]+\\.[0-9]+$' ]; then - if ! grep -q "## $TAG_VERSION" CHANGELOG.md; then - echo "No changelog entry found for version $TAG_VERSION." - exit 1 - else - echo "Changelog entry found for version $TAG_VERSION." - fi - else - echo "Pre-release tag, skipping changelog check" - fi - - name: Check if version type matches branch type run: | branch=$(git branch -r --contains ${{ github.ref }} --format "%(refname:lstrip=3)" | grep -v '^HEAD$') @@ -111,7 +102,7 @@ jobs: id-token: write # IMPORTANT: mandatory for trusted publishing steps: - name: Download all the dists - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: name: python-package-distributions path: dist/ @@ -120,7 +111,8 @@ jobs: github-release: name: >- - Create GitHub release and attach binaries + Sign the Python 🐍 distribution πŸ“¦ with Sigstore + and upload them to GitHub Release needs: - publish-to-pypi runs-on: ubuntu-latest @@ -130,7 +122,7 @@ jobs: id-token: write # IMPORTANT: mandatory for sigstore steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 1 - name: Get version @@ -148,23 +140,25 @@ jobs: echo "prerelease=false" >> $GITHUB_OUTPUT fi - name: Download all the dists - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: name: python-package-distributions path: dist/ - name: Sign the dists with Sigstore - uses: sigstore/gh-action-sigstore-python@v3.0.1 + uses: sigstore/gh-action-sigstore-python@v3.2.0 with: inputs: >- ./dist/*.tar.gz ./dist/*.whl - - name: Release + - name: Prerelease uses: softprops/action-gh-release@v2 + if: ${{ steps.check_prerelease.outputs.prerelease == 'true' }} with: tag_name: ${{ github.ref }} - name: Release ${{ steps.version.outputs.version }} + name: ${{ steps.version.outputs.version }} generate_release_notes: true - prerelease: ${{ steps.check_prerelease.outputs.prerelease }} # Mark as prerelease if necessary + draft: true + prerelease: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload artifact signatures to GitHub Release @@ -175,5 +169,5 @@ jobs: # sigstore-produced signatures and certificates. run: >- gh release upload - '${{ github.ref_name }}' dist/** + '${{ steps.version.outputs.version }}' dist/** --repo '${{ github.repository }}' \ No newline at end of file diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index d544cdf..57945cf 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -4,7 +4,9 @@ on: [ push, pull_request ] jobs: ruff: + permissions: + contents: read runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: chartboost/ruff-action@v1 \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b17918b..50653fc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.6.1 + rev: v0.14.0 hooks: # Run the linter. - id: ruff @@ -22,7 +22,7 @@ repos: require_serial: true files: ^(linux2mqtt)/.+\.py$ - repo: https://github.com/igorshubovych/markdownlint-cli - rev: v0.41.0 + rev: v0.44.0 hooks: - id: markdownlint args: ["--disable=MD013"] diff --git a/.vscode/settings.json b/.vscode/settings.json index 06a4062..a0ffc79 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -13,7 +13,6 @@ "mqtt", "mypy", "nics", - "numpy", "paho", "percpu", "pernic", diff --git a/CHANGELOG.md b/CHANGELOG.md index 625ed37..c0ca98e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # CHANGELOG +## DEPRECATED + + This changelog is no longer maintained and will not be updated in future releases. Please refer to the [release notes](https://github.com/miaucl/linux2mqtt/releases/latest) on GitHub for the latest changes. + +## 1.6.0 + +* Improve network statistics gathering while reducing cpu load @pe-pe + +## 1.5.1 + +* Fix device_class for home assistant for network metrics @pe-pe + +## 1.5.0 + +* Add home assistant option to disable attributes and only use entities + +## 1.4.1 + +* Fix package manager cli default arg + ## 1.4.0 * Add package manager available updates (supported: apt,apk,yum) diff --git a/README.md b/README.md index 7f353c6..2bfc78e 100755 --- a/README.md +++ b/README.md @@ -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. @@ -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) @@ -166,6 +176,12 @@ upper_bound: 100 ![Example card in Home Assistant](https://github.com/miaucl/linux2mqtt/blob/master/docs/example_card.png?raw=true) +### Entities vs Attributes + +Home assistant is moving away from `attributes` for additional data towards only entities. This is a slow process and currently attributes are the default way for this library, but it may be changed with following argument `--homeassistant-disable-attributes` + +> HA doesn’t modify the number of sig figs for attributes because attributes are meant to be raw data. HA in general is moving away from them in favor of entities so that users can control things like sig fig. + ## Documentation Using `mkdocs`, the documentation and reference is generated and available on [github pages](https://miaucl.github.io/linux2mqtt/). @@ -195,11 +211,19 @@ Following VSCode integrations may be helpful: * [mypy](https://marketplace.visualstudio.com/items?itemName=matangover.mypy) * [markdownlint](https://marketplace.visualstudio.com/items?itemName=DavidAnson.vscode-markdownlint) +To manually run the precommit checks without the pre-commit tool (this does +need the `requirements_dev.txt` to be installed as shown above): + +```bash +ruff check +mypy linux2mqtt +``` + ### Releasing -It is only possible to release a _final version_ on the `master` branch. For it to pass the gates of the `publish` workflow, it must have the same version in the `tag` and the `bring_api/__init__.py` and an entry in the `CHANGELOG.md` file. +A _final version_ can only be released from the `master` branch. To pass the gates of the `publish` workflow, the version must match in both the `tag` and `linux2mqtt/__init__.py`. -To release a prerelease version, no changelog entry is required, but it can only happen on a feature branch (**not** `master` branch). Also, prerelease versions are marked as such in the github release page. +To release a prerelease version, it must be done from a feature branch (**not** `master`). Prerelease versions are explicitly marked as such on the GitHub release page. ## Credits diff --git a/docs/authors.md b/docs/authors.md index 6b37b13..5e9ab0d 100644 --- a/docs/authors.md +++ b/docs/authors.md @@ -7,8 +7,9 @@ ## Contributors * Adam Cooper [@GenericStudent](https://github.com/GenericStudent) - -Why not come join us and contribute? +* PePe [@pe-pe](https://github.com/pe-pe) +* Matthijs Kooijman [@matthijskooijman](https://github.com/matthijskooijman) +* Endymion78 [@Endymion78](https://github.com/Endymion78) ## Further Credits diff --git a/linux2mqtt/__init__.py b/linux2mqtt/__init__.py index 6080ff6..c130ba1 100644 --- a/linux2mqtt/__init__.py +++ b/linux2mqtt/__init__.py @@ -1,6 +1,6 @@ """linux2mqtt package.""" -__version__ = "1.4.0" +__version__ = "1.8.2" from .const import ( DEFAULT_CONFIG, diff --git a/linux2mqtt/const.py b/linux2mqtt/const.py index aa5b6e8..94d95f0 100644 --- a/linux2mqtt/const.py +++ b/linux2mqtt/const.py @@ -6,6 +6,7 @@ LOG_LEVEL_DEFAULT = "DEBUG" HOMEASSISTANT_PREFIX_DEFAULT = "homeassistant" +HOMEASSISTANT_DISABLE_ATTRIBUTES_DEFAULT = False MQTT_CLIENT_ID_DEFAULT = "linux2mqtt" MQTT_PORT_DEFAULT = 1883 MQTT_TIMEOUT_DEFAULT = 30 # s @@ -32,6 +33,7 @@ { "log_level": LOG_LEVEL_DEFAULT, "homeassistant_prefix": HOMEASSISTANT_PREFIX_DEFAULT, + "homeassistant_disable_attributes": HOMEASSISTANT_DISABLE_ATTRIBUTES_DEFAULT, "linux2mqtt_hostname": f"{socket.gethostname()}_{MQTT_CLIENT_ID_DEFAULT}", "mqtt_client_id": MQTT_CLIENT_ID_DEFAULT, "mqtt_user": "", diff --git a/linux2mqtt/helpers.py b/linux2mqtt/helpers.py index 19cf28a..67f5b04 100644 --- a/linux2mqtt/helpers.py +++ b/linux2mqtt/helpers.py @@ -1,8 +1,9 @@ """linux2mqtt helpers.""" import re +from typing import TypeGuard -from .type_definitions import LinuxEntry +from .type_definitions import Addr, LinuxEntry def sanitize(val: str) -> str: @@ -42,3 +43,23 @@ def clean_for_discovery(val: LinuxEntry) -> dict[str, str | int | float | object for k, v in dict(val).items() if isinstance(v, str | int | float | object) and v not in (None, "") } + + +def is_addr(a: object) -> TypeGuard[Addr]: + """Check if an object is an address tuple.""" + return ( + isinstance(a, tuple) + and len(a) == 2 + and isinstance(a[0], str) + and isinstance(a[1], int) + ) + + +def addr_ip(a: Addr) -> str: + """Get the IP part of an address tuple.""" + return a[0] + + +def addr_port(a: Addr) -> int: + """Get the port part of an address tuple.""" + return a[1] diff --git a/linux2mqtt/linux2mqtt.py b/linux2mqtt/linux2mqtt.py index 80ce05e..3de607a 100755 --- a/linux2mqtt/linux2mqtt.py +++ b/linux2mqtt/linux2mqtt.py @@ -4,17 +4,22 @@ import argparse import json import logging -from os import geteuid +from logging.handlers import RotatingFileHandler +from os import geteuid, path import os +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 uuid import paho.mqtt.client +import paho.mqtt.enums import psutil from . import __version__ @@ -24,6 +29,8 @@ DEFAULT_INTERVAL, DEFAULT_NET_INTERVAL, DEFAULT_PACKAGE_INTERVAL, + HOMEASSISTANT_DISABLE_ATTRIBUTES_DEFAULT, + HOMEASSISTANT_PREFIX_DEFAULT, MAX_CONNECTIONS_INTERVAL, MAX_CPU_INTERVAL, MAX_INTERVAL, @@ -95,7 +102,7 @@ class Linux2Mqtt: cfg: Linux2MqttConfig metrics: list[BaseMetric] - connected: bool + first_connection_event: Event mqtt: paho.mqtt.client.Client @@ -134,7 +141,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"]) @@ -184,33 +191,54 @@ def connect(self) -> None: """ try: self.mqtt = paho.mqtt.client.Client( - callback_api_version=paho.mqtt.client.CallbackAPIVersion.VERSION2, # type: ignore[attr-defined, call-arg] - client_id=self.cfg["mqtt_client_id"], + callback_api_version=paho.mqtt.enums.CallbackAPIVersion.VERSION2, + client_id=f"{self.cfg['mqtt_client_id']}_{uuid.uuid4().hex[:6]}", ) 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.reconnect_delay_set(min_delay=1, max_delay=300) + 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. @@ -222,28 +250,81 @@ 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._mqtt_send(self.version_topic, self.version, retain=True) + self._create_discovery_topics() + 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, + *_args: Any, + **_kwargs: Any, + ) -> 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) + _args + Any additional args + _kwargs + Any additional kwargs + + """ + # Case 1: clean disconnect or MQTT v5 reason code + if hasattr(reason_code, "getName"): + if reason_code == 0: + main_logger.warning("Disconnected from MQTT broker.") + else: + main_logger.error( + "Disconnected: ReasonCode %s (%s)", + getattr(reason_code, "value", "n/a"), + reason_code.getName(), + ) + + # Case 2: connection refused / network failure else: - main_logger.error("Connection refused") + main_logger.error( + "Disconnected before CONNACK (likely auth or network issue): %s", + reason_code, + ) def _mqtt_send(self, topic: str, payload: str, retain: bool = False) -> None: """Send a mqtt payload to for a topic. @@ -264,7 +345,8 @@ def _mqtt_send(self, topic: str, payload: str, retain: bool = False) -> None: """ try: - main_logger.debug("Sending to MQTT: %s: %s", topic, payload) + if main_logger.isEnabledFor(logging.DEBUG): + main_logger.debug("Sending to MQTT: %s: %s", topic, payload) self.mqtt.publish( topic, payload=payload, qos=self.cfg["mqtt_qos"], retain=retain ) @@ -287,17 +369,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) @@ -325,11 +409,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: @@ -347,19 +427,24 @@ def _create_discovery_topics(self) -> None: """ for metric in self.metrics: - discovery_entry = metric.get_discovery( - self.state_topic, self.availability_topic, self._device_definition() + discovery_entries = metric.get_discovery( + self.state_topic, + self.status_topic, + self.availability_topic, + self._device_definition(), + self.cfg["homeassistant_disable_attributes"], ) discovery_topic = ( self.discovery_sensor_topic if metric.ha_sensor_type == "sensor" else self.discovery_binary_sensor_topic ) - self._mqtt_send( - discovery_topic.format(metric.name_sanitized), - json.dumps(clean_for_discovery(discovery_entry)), - retain=True, - ) + for discovery_entry in discovery_entries: + self._mqtt_send( + discovery_topic.format(sanitize(discovery_entry["name"])), + json.dumps(clean_for_discovery(discovery_entry)), + retain=True, + ) self._report_status( self.availability_topic.format(metric.name_sanitized), True ) @@ -426,11 +511,9 @@ 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() while True: try: for metric in self.metrics: @@ -452,6 +535,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. @@ -521,9 +653,17 @@ def main() -> None: ) parser.add_argument( "--homeassistant-prefix", - default="homeassistant", + default=HOMEASSISTANT_PREFIX_DEFAULT, help="MQTT discovery topic prefix (default: homeassistant)", ) + parser.add_argument( + "--homeassistant-disable-attributes", + nargs="?", + type=bool, + const=True, + default=HOMEASSISTANT_DISABLE_ATTRIBUTES_DEFAULT, + help="Disable homeassistant attributes and expose everything as entities (default: False)", + ) parser.add_argument( "--topic-prefix", default="linux", help="MQTT topic prefix (default: linux)" ) @@ -594,6 +734,11 @@ def main() -> None: help="Publish hard drive stats if available", action="store_true", ) + parser.add_argument( + "--logdir", + default=None, + help="Enables logging to specified directory (default: None)", + ) try: args = parser.parse_args() @@ -604,16 +749,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 @@ -622,6 +758,7 @@ def main() -> None: { "log_level": log_level, "homeassistant_prefix": args.homeassistant_prefix, + "homeassistant_disable_attributes": args.homeassistant_disable_attributes, "linux2mqtt_hostname": args.name, "mqtt_client_id": args.client, "mqtt_user": args.username, @@ -667,17 +804,17 @@ def main() -> None: stats.add_metric(net) if args.temp: - st = psutil.sensors_temperatures() # type: ignore[attr-defined] + st = psutil.sensors_temperatures() for device in st: - for thermal_zone in st[device]: - tm = TempMetrics(device=device, thermal_zone=thermal_zone.label) + for idx, thermal_zone in enumerate(st[device]): + tm = TempMetrics(device=device, idx=idx, label=thermal_zone.label) stats.add_metric(tm) if args.fan: - fans = psutil.sensors_fans() # type: ignore[attr-defined] + fans = psutil.sensors_fans() for device in fans: - for fan in fans[device]: - fm = FanSpeedMetrics(device=device, fan=fan.label) + for idx, fan in enumerate(fans[device]): + fm = FanSpeedMetrics(device=device, idx=idx, label=fan.label) stats.add_metric(fm) if args.packages: diff --git a/linux2mqtt/metrics.py b/linux2mqtt/metrics.py index 4b15438..1936c4c 100755 --- a/linux2mqtt/metrics.py +++ b/linux2mqtt/metrics.py @@ -2,14 +2,13 @@ import logging from queue import Queue +import socket import threading import time from typing import Any, Self import jsons -import numpy as np import psutil -from psutil._common import addr from .const import ( MAX_CPU_INTERVAL, @@ -24,10 +23,10 @@ Linux2MqttMetricsException, NoPackageManagerFound, ) -from .helpers import sanitize +from .helpers import addr_ip, addr_port, is_addr, sanitize from .package_manager import PackageManager, get_package_manager +from .type_definitions import LinuxDeviceEntry, LinuxEntry, MetricEntities, SensorType from .harddrive import HardDrive, get_hard_drive -from .type_definitions import LinuxDeviceEntry, LinuxEntry, SensorType metric_logger = logging.getLogger("metrics") @@ -59,6 +58,7 @@ class BaseMetric: device_class: str | None = None icon: str | None = None state_field: str = "state" + homeassistant_entities: list[MetricEntities] = [] ha_sensor_type: SensorType = "sensor" @@ -71,19 +71,25 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: def get_discovery( self, state_topic: str, + linux2mqtt_availability_topic: str, availability_topic: str, device_definition: LinuxDeviceEntry, - ) -> LinuxEntry: + disable_attributes: bool, + ) -> list[LinuxEntry]: """Get the discovery topic config data. Parameters ---------- state_topic The state topic where to find the data for state and attributes + linux2mqtt_availability_topic + The availability topic for linux2mqtt availability_topic The availability topic for the entry device_definition - The device entry fro the homeassistant config + The device entry for the homeassistant config + disable_attributes + Should only one entity be created with attributes or all data as entities Returns ------- @@ -91,24 +97,61 @@ def get_discovery( The homeassistant config entry """ - return LinuxEntry( - { - "name": self.name, - "unique_id": f"{device_definition['identifiers']}_{self.name_sanitized}", - "availability_topic": availability_topic.format(self.name_sanitized), - "payload_available": "online", - "payload_not_available": "offline", - "state_topic": state_topic.format(self.name_sanitized), - "value_template": f"{{{{ value_json.{self.state_field} if value_json is not undefined and value_json.{self.state_field} is not undefined else None }}}}", - "unit_of_measurement": self.unit_of_measurement, - "icon": self.icon, - "device_class": self.device_class, - "payload_on": "on", - "payload_off": "off", - "device": device_definition, - "json_attributes_topic": state_topic.format(self.name_sanitized), - "qos": 1, - } + return ( + [ + LinuxEntry( + { + "name": self.name, + "unique_id": f"{device_definition['identifiers']}_{self.name_sanitized}", + "availability": [ + {"topic": linux2mqtt_availability_topic}, + {"topic": availability_topic.format(self.name_sanitized)}, + ], + "availability_mode": "all", + "payload_available": "online", + "payload_not_available": "offline", + "state_topic": state_topic.format(self.name_sanitized), + "value_template": f"{{{{ value_json.{self.state_field} if value_json is not undefined and value_json.{self.state_field} is not undefined else None }}}}", + "unit_of_measurement": self.unit_of_measurement, + "icon": self.icon, + "device_class": self.device_class, + "payload_on": "on", + "payload_off": "off", + "device": device_definition, + "json_attributes_topic": state_topic.format( + self.name_sanitized + ), + "qos": 1, + } + ) + ] + if not disable_attributes + else [ + LinuxEntry( + { + "name": entity["name"], + "unique_id": f"{device_definition['identifiers']}_{sanitize(entity['name'])}", + "availability": [ + {"topic": linux2mqtt_availability_topic}, + {"topic": availability_topic.format(self.name_sanitized)}, + ], + "availability_mode": "all", + "payload_available": "online", + "payload_not_available": "offline", + "state_topic": state_topic.format(self.name_sanitized), + "value_template": f"{{{{ value_json.{entity['state_field']} if value_json is not undefined and value_json.{entity['state_field']} is not undefined else None }}}}", + "unit_of_measurement": entity["unit_of_measurement"], + "icon": entity["icon"], + "device_class": entity["device_class"], + "payload_on": "on", + "payload_off": "off", + "device": device_definition, + "json_attributes_topic": None, + "qos": 1, + } + ) + for entity in self.homeassistant_entities + ] ) def poll(self, result_queue: Queue[Any]) -> bool: @@ -231,10 +274,34 @@ class CPUMetrics(BaseMetric): """ - _name = "cpu" + _name = "CPU" icon = "mdi:chip" unit_of_measurement = "%" state_field = "used" + homeassistant_entities = [ + MetricEntities( + { + "name": f"CPU {f}", + "state_field": f, + "icon": "mdi:chip", + "unit_of_measurement": "%", + "device_class": None, + } + ) + for f in [ + "user", + "nice", + "system", + "idle", + "iowait", + "irq", + "softirq", + "steal", + "guest", + "guest_nice", + "used", + ] + ] interval: int @@ -306,6 +373,36 @@ class VirtualMemoryMetrics(BaseMetric): device_class = "data_size" unit_of_measurement = "MB" state_field = "used" + homeassistant_entities = [ + MetricEntities( + { + "name": "Virtual Memory", + "state_field": "percent", + "icon": "mdi:memory", + "unit_of_measurement": "%", + "device_class": None, + } + ), + *[ + MetricEntities( + { + "name": f"Virtual Memory {f}", + "state_field": f, + "icon": "mdi:memory", + "unit_of_measurement": "MB", + "device_class": "data_size", + } + ) + for f in [ + "total", + "available", + "used", + "free", + "active", + "inactive", + ] + ], + ] def poll(self, result_queue: Queue[Self]) -> bool: """Poll new data for the virtual memory metric. @@ -370,6 +467,33 @@ def __init__(self, mountpoint: str): super().__init__() self.mountpoint = mountpoint self._name = self._name_template.format(mountpoint) + self.homeassistant_entities = [ + MetricEntities( + { + "name": self._name_template.format(mountpoint), + "state_field": "percent", + "icon": "mdi:harddisk", + "unit_of_measurement": "%", + "device_class": None, + } + ), + *[ + MetricEntities( + { + "name": f"{self._name_template.format(mountpoint)} {f}", + "state_field": f, + "icon": "mdi:harddisk", + "unit_of_measurement": "GB", + "device_class": "data_size", + } + ) + for f in [ + "total", + "used", + "free", + ] + ], + ] def poll(self, result_queue: Queue[Self]) -> bool: """Poll new data for the virtual memory metric. @@ -423,14 +547,14 @@ def __init__( interval: int, nic: str, ): - """Initialize the cpu thread. + """Initialize the network thread. Parameters ---------- result_queue: Queue[BaseMetric] The queue to put the metric into once the data is gathered metric - The cpu metric to gather data for + The network metric to gather data for interval The interval to gather data over nic @@ -444,7 +568,7 @@ def __init__( self.nic = nic def run(self) -> None: - """Run the cpu thread. Once data is gathered, it is put into the queue and the thread exits. + """Run the network thread. Once data is gathered, it is put into the queue and the thread exits. Raises ------ @@ -453,46 +577,44 @@ def run(self) -> None: """ try: - x = 0 - interval = self.interval - tx_bytes = [] - rx_bytes = [] - prev_tx = 0 - prev_rx = 0 - base_tx = 0 - base_rx = 0 - while x < interval: - nics = psutil.net_io_counters(pernic=True) - if self.nic in nics: - tx = nics[self.nic].bytes_sent - rx = nics[self.nic].bytes_recv - if tx < prev_tx: - # TX counter rollover - base_tx += prev_tx - if rx < prev_rx: - # RX counter rollover - base_rx += prev_rx - tx_bytes.append(base_tx + tx) - rx_bytes.append(base_rx + rx) - prev_tx = tx - prev_rx = rx - time.sleep(1) - x += 1 - + start_tx = 0 + start_rx = 0 + # get initial counters + nics = psutil.net_io_counters(pernic=True) if self.nic in nics: - tx_rate_bytes_sec = np.average(np.diff(np.array(tx_bytes))) - tx_rate = tx_rate_bytes_sec / 125.0 # bytes/sec to kilobits/sec - rx_rate_bytes_sec = np.average(np.diff(np.array(rx_bytes))) - rx_rate = rx_rate_bytes_sec / 125.0 # bytes/sec to kilobits/sec - - self.metric.polled_result = { - "total_rate": int(tx_rate + rx_rate), - "tx_rate": int(tx_rate), - "rx_rate": int(rx_rate), - } - self.result_queue.put(self.metric) + start_tx = nics[self.nic].bytes_sent + start_rx = nics[self.nic].bytes_recv else: metric_logger.warning("Network %s not available", self.nic) + return + time.sleep(self.interval) + # get counters after interval + nics = psutil.net_io_counters(pernic=True) + if self.nic in nics: + end_tx = nics[self.nic].bytes_sent + end_rx = nics[self.nic].bytes_recv + else: + metric_logger.warning("Network %s not available", self.nic) + return + # handle counter rollover by ignoring bytes from start_tx/start_rx to maxvalue + if end_tx >= start_tx: + diff_tx = end_tx - start_tx + else: + diff_tx = end_tx + if end_rx >= start_rx: + diff_rx = end_rx - start_rx + else: + diff_rx = end_rx + # calculate rate bytes/sec and convert bytes to kilobits/sec + tx_rate = diff_tx / self.interval / 125.0 + rx_rate = diff_rx / self.interval / 125.0 + + self.metric.polled_result = { + "total_rate": int(tx_rate + rx_rate), + "tx_rate": int(tx_rate), + "rx_rate": int(rx_rate), + } + self.result_queue.put(self.metric) except Exception as ex: raise Linux2MqttMetricsException( "Could not gather and publish network data" @@ -542,6 +664,22 @@ def __init__(self, nic: str, interval: int): self.interval = interval self.nic = nic self._name = self._name_template.format(nic) + self.homeassistant_entities = [ + MetricEntities( + { + "name": f"{self._name_template.format(nic)} {f}", + "state_field": f, + "icon": "mdi:server-network", + "unit_of_measurement": "kbit/s", + "device_class": "data_rate", + } + ) + for f in [ + "total_rate", + "tx_rate", + "rx_rate", + ] + ] if interval < MIN_NET_INTERVAL: raise ValueError( @@ -594,6 +732,22 @@ class NetConnectionMetrics(BaseMetric): device_class = "" unit_of_measurement = "" state_field = "count" + homeassistant_entities = [ + MetricEntities( + { + "name": f"Network connections {f}", + "state_field": f, + "icon": "mdi:ip-network", + "unit_of_measurement": None, + "device_class": None, + } + ) + for f in [ + "total", + "ipv4", + "ipv6", + ] + ] def __init__(self, interval: int) -> None: """Extract local IPs for evaluation during poll. @@ -611,7 +765,7 @@ def __init__(self, interval: int) -> None: for snicaddrs in interface_addrs.values(): for snicaddr in snicaddrs: - if snicaddr.family.value in (2, 10): + if snicaddr.family in (socket.AF_INET, socket.AF_INET6): self.ips.add(snicaddr.address) def poll(self, result_queue: Queue[Self]) -> bool: @@ -635,55 +789,57 @@ def poll(self, result_queue: Queue[Self]) -> bool: """ try: st = psutil.net_connections() + listening_ports = { - x.laddr.port + addr_port(x.laddr) for x in st if x.status == "LISTEN" - and isinstance(x.laddr, addr) - and x.laddr.ip in ("0.0.0.0", "::") + and is_addr(x.laddr) + and addr_ip(x.laddr) in ("0.0.0.0", "::") } + established = [x for x in st if x.status == "ESTABLISHED"] + self.polled_result = { - "count": len([x for x in st if x.status == "ESTABLISHED"]), + "count": len(established), # deprecated + "total": len(established), "ipv4": len( [ x - for x in st - if x.family.value == 2 - and x.status == "ESTABLISHED" - and isinstance(x.laddr, addr) - and not x.laddr.ip.startswith("127.") + for x in established + if x.family == socket.AF_INET + and is_addr(x.laddr) + and not addr_ip(x.laddr).startswith("127.") ] ), "ipv6": len( [ x - for x in st - if x.family.value == 10 - and x.status == "ESTABLISHED" - and isinstance(x.laddr, addr) - and x.laddr.ip != "::1" + for x in established + if x.family == socket.AF_INET6 + and is_addr(x.laddr) + and addr_ip(x.laddr) != "::1" ] ), "listening_ports": list(listening_ports), "outbound": [ - f"{x.raddr.ip}:{x.raddr.port}" - for x in st - if x.status == "ESTABLISHED" - and isinstance(x.laddr, addr) - and isinstance(x.raddr, addr) - and x.laddr.ip in self.ips - and x.raddr.ip not in ("::1", "127.0.0.1") + f"{addr_ip(x.raddr)}:{addr_port(x.raddr)}" + for x in established + if is_addr(x.laddr) + and is_addr(x.raddr) + and addr_ip(x.laddr) in self.ips + and addr_ip(x.raddr) not in ("127.0.0.1", "::1") ], "inbound": [ - f"{x.raddr.ip}:{x.raddr.port} -> {x.laddr.ip}:{x.laddr.port}" - for x in st - if x.status == "ESTABLISHED" - and isinstance(x.laddr, addr) - and isinstance(x.raddr, addr) - and x.laddr.port in listening_ports + f"{addr_ip(x.raddr)}:{addr_port(x.raddr)} -> " + f"{addr_ip(x.laddr)}:{addr_port(x.laddr)}" + for x in established + if is_addr(x.laddr) + and is_addr(x.raddr) + and addr_port(x.laddr) in listening_ports ], } + except Exception as ex: raise Linux2MqttMetricsException( "Could not gather and publish net connections" @@ -702,17 +858,20 @@ class TempMetrics(BaseMetric): _name_template = "Thermal Zone ({}/{})" _device: str - _thermal_zone: str + _idx: int + _label: str - def __init__(self, device: str, thermal_zone: str): + def __init__(self, device: str, idx: int, label: str): """Initialize the thermal zone metric. Parameters ---------- device The device - thermal_zone - The thermal zone + idx + The 0-based index of the thermal zone within the list of zones for this device + label + The label of the zone (can be empty) Raises ------ @@ -722,8 +881,26 @@ def __init__(self, device: str, thermal_zone: str): """ super().__init__() self._device = device - self._thermal_zone = thermal_zone - self._name = self._name_template.format(device, thermal_zone) + self._idx = idx + if not label: + # 1-based to match lm-sensors + label = f"temp{idx + 1}" + self._label = label + self._name = self._name_template.format(device, label) + self.homeassistant_entities = [ + MetricEntities( + { + "name": f"{self._name_template.format(device, label)} {f}", + "state_field": f, + "icon": "mdi:thermometer", + "unit_of_measurement": "Β°C", + "device_class": "temperature", + } + ) + for f in [ + "current", + ] + ] def poll(self, result_queue: Queue[Self]) -> bool: """Poll new data for the thermal zone metric. @@ -745,18 +922,13 @@ def poll(self, result_queue: Queue[Self]) -> bool: """ try: - st = psutil.sensors_temperatures() # type: ignore[attr-defined] - thermal_zone = next( - ( - item - for item in st.get(self._device, []) - if item.label == self._thermal_zone - ), - None, - ) + st = psutil.sensors_temperatures() + dev = st.get(self._device) + assert dev + thermal_zone = dev[self._idx] assert thermal_zone self.polled_result = { - "label": thermal_zone.label, + "label": self._label, "current": thermal_zone.current, "high": thermal_zone.high, "critical": thermal_zone.critical, @@ -779,17 +951,20 @@ class FanSpeedMetrics(BaseMetric): _name_template = "Fan Speed ({}/{})" _device: str - _fan: str + _idx: int + _label: str - def __init__(self, device: str, fan: str): + def __init__(self, device: str, idx: int, label: str): """Initialize the fan speed metric. Parameters ---------- device The device - fan - The fan + idx + The 0-based index of the fan within the list of fans for this device + label + The label of the fan (can be empty) Raises ------ @@ -799,11 +974,29 @@ def __init__(self, device: str, fan: str): """ super().__init__() self._device = device - self._fan = fan - self._name = self._name_template.format(device, fan) + self._idx = idx + if not label: + # 1-based to match lm-sensors + label = f"fan{idx + 1}" + self._label = label + self._name = self._name_template.format(device, label) + self.homeassistant_entities = [ + MetricEntities( + { + "name": f"{self._name_template.format(device, label)} {f}", + "state_field": f, + "icon": "mdi:fan", + "unit_of_measurement": None, + "device_class": None, + } + ) + for f in [ + "current", + ] + ] def poll(self, result_queue: Queue[Self]) -> bool: - """Poll new data for the thermal zone metric. + """Poll new data for the fan speed metric. Parameters ---------- @@ -822,14 +1015,13 @@ def poll(self, result_queue: Queue[Self]) -> bool: """ try: - st = psutil.sensors_fans() # type: ignore[attr-defined] - fan = next( - (item for item in st.get(self._device, []) if item.label == self._fan), - None, - ) + st = psutil.sensors_fans() + dev = st.get(self._device) + assert dev + fan = dev[self._idx] assert fan self.polled_result = { - "label": fan.label, + "label": self._label, "current": fan.current, "unit": "rpm", } @@ -882,7 +1074,8 @@ def run(self) -> None: self.package_manager.update_if_needed() updates_available = self.package_manager.get_available_updates() self.metric.polled_result = { - "count": len(updates_available), + "count": len(updates_available), # deprecated + "total": len(updates_available), "packages": updates_available, } self.result_queue.put(self.metric) @@ -906,6 +1099,17 @@ class PackageUpdateMetrics(BaseMetric): device_class = "" unit_of_measurement = "" state_field = "count" + homeassistant_entities = [ + MetricEntities( + { + "name": "Package updates", + "state_field": "total", + "icon": "mdi:package-up", + "unit_of_measurement": None, + "device_class": None, + } + ) + ] _name = "Package Updates" package_manager: PackageManager diff --git a/linux2mqtt/type_definitions.py b/linux2mqtt/type_definitions.py index 4784aec..85c951f 100644 --- a/linux2mqtt/type_definitions.py +++ b/linux2mqtt/type_definitions.py @@ -1,6 +1,8 @@ """linux2mqtt type definitions.""" -from typing import Literal, TypedDict +from typing import Literal, NotRequired, TypedDict + +Addr = tuple[str, int] StatusType = Literal["online", "offline"] """Metric status""" @@ -18,6 +20,8 @@ class Linux2MqttConfig(TypedDict): Log verbosity homeassistant_prefix MQTT discovery topic prefix + homeassistant_disable_attributes + Disable attributes in home assistant discovery and exposes everything as entities linux2mqtt_hostname A descriptive name for the system being monitored mqtt_client_id @@ -43,6 +47,7 @@ class Linux2MqttConfig(TypedDict): log_level: str homeassistant_prefix: str + homeassistant_disable_attributes: bool linux2mqtt_hostname: str mqtt_client_id: str mqtt_user: str @@ -66,12 +71,65 @@ class LinuxDeviceEntry(TypedDict): The name of the device to display in home assistant model The model of the device as additional info + hw_version + The hardware version of the device (OS version) + sw_version + The software version of the device (Linux2mqtt version) """ identifiers: str name: str model: str + hw_version: NotRequired[str] + sw_version: NotRequired[str] + + +class MetricEntities(TypedDict): + """A metric entity object for discovery in home assistant. + + Attributes + ---------- + name + The name of the sensor to display in home assistant + state_field + The field in the state topic to extract the state value from + icon + The icon of the sensor to display + unit_of_measurement + The unit of measurement of the sensor + device_class + The device class of the sensor + + """ + + name: str + state_field: str + icon: str | None + unit_of_measurement: str | None + device_class: str | None + + +class AvailabilityEntry(TypedDict): + """An availability entity object for discovery in home assistant. + + Attributes + ---------- + topic + The MQTT topic receiving availability updates + value_template + Template to extract device's availability from the topic + payload_available + The payload that represents the available state + payload_not_available + The payload that represents the unavailable state + + """ + + topic: str + value_template: NotRequired[str] + payload_available: NotRequired[str] + payload_not_available: NotRequired[str] class LinuxEntry(TypedDict): @@ -85,8 +143,10 @@ class LinuxEntry(TypedDict): The unique id of the sensor in home assistant icon The icon of the sensor to display - availability_topic - The topic to check the availability of the sensor + availability + The list of topics to check the availability of the sensor + availability_mode + The conditions needed to set the entity to available payload_available The payload of availability_topic of the sensor when available payload_unavailable @@ -105,7 +165,7 @@ class LinuxEntry(TypedDict): The device the sensor is attributed to device_class The device class of the sensor - state_topic + json_attributes_topic The topic containing all information for the attributes of the sensor qos The QOS of the discovery message @@ -115,7 +175,8 @@ class LinuxEntry(TypedDict): name: str unique_id: str icon: str | None - availability_topic: str + availability: list[AvailabilityEntry] + availability_mode: NotRequired[str] payload_available: str payload_not_available: str state_topic: str @@ -125,5 +186,5 @@ class LinuxEntry(TypedDict): payload_off: str device: LinuxDeviceEntry device_class: str | None - json_attributes_topic: str + json_attributes_topic: str | None qos: int diff --git a/mkdocs.yml b/mkdocs.yml index 572d5be..8903823 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -49,8 +49,8 @@ plugins: - mkdocstrings: handlers: python: - import: - - https://docs.python.org/3.11/objects.inv + inventories: + - https://docs.python.org/3.12/objects.inv options: docstring_style: numpy merge_init_into_class: true diff --git a/pyproject.toml b/pyproject.toml index 40f4a6b..64c6d15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,17 +10,15 @@ name = "linux2mqtt" dynamic = ["version", "readme"] description = "Send your linux stats and and events to mqtt and discovery them in home assistant." authors = [ - { name = "Cyrill Raccaud", email = "cyrill.raccaud+pypi@gmail.com" }, - { name = "Adam Cooper" } + { name = "Cyrill Raccaud", email = "cyrill.raccaud+pypi@gmail.com" } ] dependencies = [ "jsons", "psutil", "paho-mqtt", - "numpy", "typing-extensions" ] -requires-python = ">=3.11" +requires-python = ">=3.12" classifiers = [ "Programming Language :: Python :: 3", diff --git a/requirements.txt b/requirements.txt index a20a37a..2c6358f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ paho-mqtt jsons psutil -numpy typing-extensions \ No newline at end of file diff --git a/requirements_dev.txt b/requirements_dev.txt index 8aaeeeb..85f6525 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,9 +1,7 @@ paho-mqtt -types-paho-mqtt jsons psutil types-psutil -numpy typing-extensions mypy pydantic From 14149c0baaed4358c1e06e292b77fb5ac3995084 Mon Sep 17 00:00:00 2001 From: Bimal Shah Date: Sat, 7 Feb 2026 14:02:53 -0500 Subject: [PATCH 14/20] Fix error with smartctl return codes for NVME --- linux2mqtt/harddrive.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linux2mqtt/harddrive.py b/linux2mqtt/harddrive.py index f01834a..50fd113 100644 --- a/linux2mqtt/harddrive.py +++ b/linux2mqtt/harddrive.py @@ -44,7 +44,7 @@ def _get_attributes(self): ) as proc: stdout, stderr = proc.communicate(timeout=30) - if (proc.returncode&7) != 0: + if (proc.returncode&3) != 0: raise HardDriveException( f"Something went wrong with smartctl: {proc.returncode}: '{stderr}'" ) From f8d4539bab47da7fd0aad131ae8ba79cb363ae79 Mon Sep 17 00:00:00 2001 From: Bimal Shah Date: Sat, 7 Feb 2026 17:58:19 -0500 Subject: [PATCH 15/20] update harddrive exception --- linux2mqtt/exceptions.py | 3 --- linux2mqtt/harddrive.py | 5 ++--- linux2mqtt/linux2mqtt.py | 22 ++++++---------------- 3 files changed, 8 insertions(+), 22 deletions(-) diff --git a/linux2mqtt/exceptions.py b/linux2mqtt/exceptions.py index 305bfaa..96b7288 100644 --- a/linux2mqtt/exceptions.py +++ b/linux2mqtt/exceptions.py @@ -26,6 +26,3 @@ class PackageManagerException(Linux2MqttException): class HardDriveException(Linux2MqttException): """Generic Hard Drive exception occured.""" - -class HardDriveIDException(Linux2MqttException): - """Generic Hard Drive exception occured.""" \ No newline at end of file diff --git a/linux2mqtt/harddrive.py b/linux2mqtt/harddrive.py index 50fd113..0f06a87 100644 --- a/linux2mqtt/harddrive.py +++ b/linux2mqtt/harddrive.py @@ -8,7 +8,7 @@ from time import time import re -from .exceptions import HardDriveException, HardDriveIDException, Linux2MqttException +from .exceptions import HardDriveException, Linux2MqttException class HardDrive: @@ -213,6 +213,5 @@ def get_hard_drive(device_name:str) -> HardDrive: elif r2.match(device_name): return NVME(device_name) else: - raise HardDriveIDException("Harddrive ID not supported") + raise HardDriveException("Harddrive ID not supported") - diff --git a/linux2mqtt/linux2mqtt.py b/linux2mqtt/linux2mqtt.py index b9cd606..0293178 100755 --- a/linux2mqtt/linux2mqtt.py +++ b/linux2mqtt/linux2mqtt.py @@ -5,9 +5,8 @@ import json import logging from logging.handlers import RotatingFileHandler -from os import geteuid, path +from os import geteuid, path, listdir from pathlib import Path -import os import platform from queue import Empty, Queue import signal @@ -48,7 +47,7 @@ MQTT_QOS_DEFAULT, MQTT_TIMEOUT_DEFAULT, ) -from .exceptions import HardDriveIDException, Linux2MqttConfigException, Linux2MqttConnectionException +from .exceptions import HardDriveException, Linux2MqttConfigException, Linux2MqttConnectionException from .helpers import clean_for_discovery, sanitize from .metrics import ( BaseMetric, @@ -64,16 +63,8 @@ ) from .type_definitions import Linux2MqttConfig, LinuxDeviceEntry -<<<<<<< HEAD - -logging.basicConfig(format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") - -main_logger = logging.getLogger("linux2mqtt") -======= main_logger = logging.getLogger("main") mqtt_logger = logging.getLogger("mqtt") ->>>>>>> master - class Linux2Mqtt: """linux2mqtt class. @@ -757,11 +748,11 @@ def main() -> None: choices=range(MIN_PACKAGE_INTERVAL, MAX_PACKAGE_INTERVAL), ) parser.add_argument( -<<<<<<< HEAD "--harddrives", help="Publish hard drive stats if available", action="store_true", -======= + ) + parser.add_argument( "--discovery", default=None, help=f"Discovery platforms enabled (default: {DISCOVERY_DEFAULT})", @@ -770,7 +761,6 @@ def main() -> None: nargs="?", const="", metavar="PLATFORM", ->>>>>>> master ) parser.add_argument( "--logdir", @@ -864,13 +854,13 @@ def main() -> None: stats.add_metric(package_updates) if args.harddrives: - for drive in os.listdir("/dev/disk/by-id/"): + for drive in listdir("/dev/disk/by-id/"): try: harddrive = HardDriveMetrics(drive) # harddrive = get_hard_drive(drive) if harddrive: stats.add_metric(harddrive) - except HardDriveIDException: + except HardDriveException: pass if not ( From c7c64466cb7334f97648d434b2856112164779e8 Mon Sep 17 00:00:00 2001 From: Bimal Shah Date: Sat, 7 Feb 2026 21:32:48 -0500 Subject: [PATCH 16/20] Code cleanup and updated readme --- README.md | 6 ++++++ linux2mqtt/harddrive.py | 14 +------------- linux2mqtt/linux2mqtt.py | 1 - linux2mqtt/metrics.py | 2 -- 4 files changed, 7 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 43c6a04..b58d9c4 100755 --- a/README.md +++ b/README.md @@ -118,6 +118,12 @@ Enabling this option will cause increased network traffic in order to update pac `linux2mqtt --name Server1 -vvvvv --packages=7200` will search for available updates every 2 hours +### Hard Drives + +`linux2mqtt` can publish the status of all harddrives using the `harddrives` option. Each hard drive will present as a separate sensor in Home Assistant. The sensor state reports the harddrive status based on a the smartctl report, which generates a score. Additional data is accessible as state attributes on each sensor. + +`linux2mqtt --name Server1 -vvvvv --interval 60 --harddrives` + ## 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. diff --git a/linux2mqtt/harddrive.py b/linux2mqtt/harddrive.py index 0f06a87..8dad674 100644 --- a/linux2mqtt/harddrive.py +++ b/linux2mqtt/harddrive.py @@ -30,7 +30,6 @@ def __init__(self, device_id: str): """ self.device_id = device_id - # self._name = self._name = self._name_template.format(device_) Use the device name from smartctl for the device name pass @@ -51,9 +50,6 @@ def _get_attributes(self): raw_json_data = json.loads(stdout) - # output = subprocess.run(command, capture_output=True) - - # raw_json_data = json.loads(output.stdout) self._attributes = raw_json_data def parse_attributes(self): @@ -66,7 +62,7 @@ def get_score(self): raise Linux2MqttException from NotImplementedError def get_status(self): - # Classification + # Arbitrary Classification Set by developer if self.score <= 10: self.status = "HEALTHY" elif self.score <= 20: @@ -122,13 +118,6 @@ def get_score(self): score += self.attributes.get('Command Timeout',0) * 1.5 score += min(self.attributes.get('UDMA CRC Error Count',0), 10) - # SMART CTL isnt consistent enough to come up with a percentage used for SSDs.... - # if 'percent_used' in attributes: - # if attributes['percent_used'] > 90: - # score += 30 - # elif attributes['percent_used'] > 80: - # score += 10 - self.score = score @@ -203,7 +192,6 @@ def get_hard_drive(device_name:str) -> HardDrive: ata_regex = "^ata.*(? None: for drive in listdir("/dev/disk/by-id/"): try: harddrive = HardDriveMetrics(drive) - # harddrive = get_hard_drive(drive) if harddrive: stats.add_metric(harddrive) except HardDriveException: diff --git a/linux2mqtt/metrics.py b/linux2mqtt/metrics.py index 32738ed..c89961c 100755 --- a/linux2mqtt/metrics.py +++ b/linux2mqtt/metrics.py @@ -1216,7 +1216,6 @@ def run(self) -> None: self.metric.polled_result = { **self.harddrive.attributes, # type: ignore[unused-ignore] } - # self.metric._name = self.harddrive.attributes['model_name'] self.result_queue.put(self.metric) except Exception as ex: raise Linux2MqttMetricsException( @@ -1227,7 +1226,6 @@ class HardDriveMetrics(BaseMetric): """Hard Drive metric.""" icon = "mdi:harddisk" - device_class = "" # TODO See if I can have categories for this unit_of_measurement = "" state_field = "status" From 4222b6c64994cb5f06b7ef346ffaee03107e9154 Mon Sep 17 00:00:00 2001 From: Bimal Shah Date: Sat, 7 Feb 2026 22:15:13 -0500 Subject: [PATCH 17/20] Cody cleaning --- linux2mqtt/harddrive.py | 194 ++++++++++++++++++++++----------------- linux2mqtt/linux2mqtt.py | 11 ++- linux2mqtt/metrics.py | 18 ++-- 3 files changed, 126 insertions(+), 97 deletions(-) diff --git a/linux2mqtt/harddrive.py b/linux2mqtt/harddrive.py index 8dad674..a34324a 100644 --- a/linux2mqtt/harddrive.py +++ b/linux2mqtt/harddrive.py @@ -1,20 +1,18 @@ -"""Hard drives""" - +"""Hard drives.""" import json -import shlex -from subprocess import DEVNULL, PIPE, STDOUT, Popen, run -# import subprocess -from time import time import re +import shlex +from subprocess import DEVNULL, PIPE, Popen from .exceptions import HardDriveException, Linux2MqttException class HardDrive: - """Base class for all harddrives to implement""" - #parameters - _attributes = None + """Base class for all harddrives to implement.""" + + # parameters + _attributes: dict | None device_id: str attributes: dict score: int @@ -27,14 +25,15 @@ def __init__(self, device_id: str): ---------- device_id The device id from /dev/disk/by-id/ - + """ self.device_id = device_id + self._attributes = None - pass - - def _get_attributes(self): - command = shlex.split(f"/usr/sbin/smartctl --info --all --json --nocheck standby /dev/disk/by-id/{self.device_id}") + def _get_attributes(self) -> None: + command = shlex.split( + f"/usr/sbin/smartctl --info --all --json --nocheck standby /dev/disk/by-id/{self.device_id}" + ) with Popen( command, stdout=PIPE, @@ -43,26 +42,25 @@ def _get_attributes(self): ) as proc: stdout, stderr = proc.communicate(timeout=30) - if (proc.returncode&3) != 0: + if (proc.returncode & 3) != 0: raise HardDriveException( f"Something went wrong with smartctl: {proc.returncode}: '{stderr}'" ) - + raw_json_data = json.loads(stdout) - + self._attributes = raw_json_data - def parse_attributes(self): + def parse_attributes(self) -> None: """Hard Drive specific parse function depending on results from smartctl.""" raise Linux2MqttException from NotImplementedError - - def get_score(self): + def get_score(self) -> None: """Hard Drive specific score function depending on results from smartctl.""" raise Linux2MqttException from NotImplementedError - - def get_status(self): - # Arbitrary Classification Set by developer + + def get_status(self) -> None: + """Convert the score to an Arbitrary Classification Set by developer.""" if self.score <= 10: self.status = "HEALTHY" elif self.score <= 20: @@ -74,113 +72,144 @@ def get_status(self): class SataDrive(HardDrive): - + """For ATA Drives.""" - def parse_attributes(self): - self.attributes = dict() + def parse_attributes(self) -> None: + """Parse out attributes from smartctl where available.""" + self.attributes = {} self._get_attributes() - ata_smart_attributes = [("Reallocated Sector Count", 5), ("Command Timeout", 38), ("Reported Uncorrectable Errors",187), - ("Current Pending Sector", 197), ("Offline Uncorrectable", 198), ("UDMA CRC Error Count", 199), - ] - - self.attributes["Model Name"] = self._attributes['model_name'] - self.attributes["Device"] = self._attributes['device']['name'] - self.attributes["Size TB"] = self._attributes['user_capacity']['bytes']/1000000000000 - self.attributes["Temperature"] = self._attributes['temperature']['current'] - self.attributes["Smart status"] = 'Healthy' if self._attributes['smart_status']['passed'] else 'Failed' - self.attributes["Power On Time"] = self._attributes['power_on_time']['hours'] - self.attributes["Power Cycle Count"] = self._attributes['power_cycle_count'] - - new_data = {item['id']: item for item in self._attributes['ata_smart_attributes']['table']} + ata_smart_attributes = [ + ("Reallocated Sector Count", 5), + ("Command Timeout", 38), + ("Reported Uncorrectable Errors", 187), + ("Current Pending Sector", 197), + ("Offline Uncorrectable", 198), + ("UDMA CRC Error Count", 199), + ] + + self.attributes["Model Name"] = self._attributes["model_name"] # type: ignore + self.attributes["Device"] = self._attributes["device"]["name"] # type: ignore + self.attributes["Size TB"] = ( + self._attributes["user_capacity"]["bytes"] / 1000000000000 # type: ignore + ) # type: ignore + self.attributes["Temperature"] = self._attributes["temperature"]["current"] # type: ignore + self.attributes["Smart status"] = ( + "Healthy" if self._attributes["smart_status"]["passed"] else "Failed" # type: ignore + ) # type: ignore + self.attributes["Power On Time"] = self._attributes["power_on_time"]["hours"] # type: ignore + self.attributes["Power Cycle Count"] = self._attributes["power_cycle_count"] # type: ignore + + new_data = { + item["id"]: item + for item in self._attributes["ata_smart_attributes"]["table"] # type: ignore + } # type: ignore for name, key in ata_smart_attributes: - tmp = new_data[key]['raw']['value'] if new_data.get(key) else None + tmp = new_data[key]["raw"]["value"] if new_data.get(key) else None if tmp is not None: self.attributes[name] = tmp self.get_score() self.get_status() - self.attributes['score'] = self.score - self.attributes['status'] = self.status - + self.attributes["score"] = self.score + self.attributes["status"] = self.status - def get_score(self): + def get_score(self) -> None: + """ATA Drive specific score function depending on results from smartctl.""" score = 0 - score += self.attributes.get('Reallocated Sector Count',0) * 2 - if self.attributes.get('Reallocated Sector Count',0) > 50: + score += self.attributes.get("Reallocated Sector Count", 0) * 2 + if self.attributes.get("Reallocated Sector Count", 0) > 50: score += 50 - score += self.attributes.get('Current Pending Sector',0) * 3 - if self.attributes.get('Current Pending Sector',0) > 10: + score += self.attributes.get("Current Pending Sector", 0) * 3 + if self.attributes.get("Current Pending Sector", 0) > 10: score += 30 - score += self.attributes.get('Offline Uncorrectable',0) * 3 - score += self.attributes.get('Reported Uncorrectable Errors',0) * 2 - score += self.attributes.get('Command Timeout',0) * 1.5 - score += min(self.attributes.get('UDMA CRC Error Count',0), 10) + score += self.attributes.get("Offline Uncorrectable", 0) * 3 + score += self.attributes.get("Reported Uncorrectable Errors", 0) * 2 + score += self.attributes.get("Command Timeout", 0) * 1.5 + score += min(self.attributes.get("UDMA CRC Error Count", 0), 10) self.score = score class NVME(HardDrive): - def parse_attributes(self): - self.attributes = dict() + """For NVME Drives.""" + + def parse_attributes(self) -> None: + """Parse NVME Smartctl attributes.""" + self.attributes = {} self._get_attributes() - nvme_smart_attributes = ['critical_warning', 'percentage_used', 'power_on_hours', 'power_cycles', 'media_errors', 'num_err_log_entries', - 'critical_comp_time', 'warning_temp_time', 'available_spare', 'available_spare_threshold'] - - self.attributes["Model Name"] = self._attributes['model_name'] - self.attributes["Device"] = self._attributes['device']['name'] - self.attributes["Size TB"] = self._attributes['user_capacity']['bytes']/1000000000000 - self.attributes["Temperature"] = self._attributes['temperature']['current'] - self.attributes["Smart status"] = 'Healthy' if self._attributes['smart_status']['passed'] else 'Failed' + nvme_smart_attributes = [ + "critical_warning", + "percentage_used", + "power_on_hours", + "power_cycles", + "media_errors", + "num_err_log_entries", + "critical_comp_time", + "warning_temp_time", + "available_spare", + "available_spare_threshold", + ] + + self.attributes["Model Name"] = self._attributes["model_name"] # type: ignore + self.attributes["Device"] = self._attributes["device"]["name"] # type: ignore + self.attributes["Size TB"] = ( + self._attributes["user_capacity"]["bytes"] / 1000000000000 # type: ignore + ) # type: ignore + self.attributes["Temperature"] = self._attributes["temperature"]["current"] # type: ignore + self.attributes["Smart status"] = ( + "Healthy" if self._attributes["smart_status"]["passed"] else "Failed" # type: ignore + ) # type: ignore for key in nvme_smart_attributes: - tmp = self._attributes['nvme_smart_health_information_log'].get(key) + tmp = self._attributes["nvme_smart_health_information_log"].get(key) # type: ignore if tmp is not None: self.attributes[key] = tmp - + self.get_score() self.get_status() - self.attributes['score'] = self.score - self.attributes['status'] = self.status + self.attributes["score"] = self.score + self.attributes["status"] = self.status - - - def get_score(self): + def get_score(self) -> None: + """Score specific for NVME Drives.""" score = 0 # Critical warnings (bitmask) - if self.attributes.get('critical_warning') != 0: + if self.attributes.get("critical_warning") != 0: score += 100 # Any critical flag = high risk # NAND wear - if self.attributes.get('percent_used',0) > 90: + if self.attributes.get("percent_used", 0) > 90: score += 50 - elif self.attributes.get('percent_used',0) > 80: + elif self.attributes.get("percent_used", 0) > 80: score += 20 - elif self.attributes.get('percent_used',0) > 70: + elif self.attributes.get("percent_used", 0) > 70: score += 10 # Media/data errors - score += self.attributes.get('media_errors',0) * 5 + score += self.attributes.get("media_errors", 0) * 5 # Error log entries - score += min(self.attributes.get('num_error_log_entries',0), 50) # cap at 50 + score += min(self.attributes.get("num_error_log_entries", 0), 50) # cap at 50 # Temperature issues - if self.attributes.get('critical_temp_time',0) > 0: + if self.attributes.get("critical_temp_time", 0) > 0: score += 30 - elif self.attributes.get('warning_temp_time',0) > 0: + elif self.attributes.get("warning_temp_time", 0) > 0: score += 10 # Available spare - if self.attributes.get('available_spare',0) < self.attributes.get('available_spare_threshold',0): + if self.attributes.get("available_spare", 0) < self.attributes.get( + "available_spare_threshold", 0 + ): score += 30 self.score = score - -def get_hard_drive(device_name:str) -> HardDrive: + +def get_hard_drive(device_name: str) -> HardDrive: """Determine the hard drive type. Returns @@ -190,8 +219,8 @@ def get_hard_drive(device_name:str) -> HardDrive: """ - ata_regex = "^ata.*(? HardDrive: return NVME(device_name) else: raise HardDriveException("Harddrive ID not supported") - diff --git a/linux2mqtt/linux2mqtt.py b/linux2mqtt/linux2mqtt.py index f098568..32ad8c5 100755 --- a/linux2mqtt/linux2mqtt.py +++ b/linux2mqtt/linux2mqtt.py @@ -5,7 +5,7 @@ import json import logging from logging.handlers import RotatingFileHandler -from os import geteuid, path, listdir +from os import geteuid, listdir, path from pathlib import Path import platform from queue import Empty, Queue @@ -47,25 +47,30 @@ MQTT_QOS_DEFAULT, MQTT_TIMEOUT_DEFAULT, ) -from .exceptions import HardDriveException, Linux2MqttConfigException, Linux2MqttConnectionException +from .exceptions import ( + HardDriveException, + Linux2MqttConfigException, + Linux2MqttConnectionException, +) from .helpers import clean_for_discovery, sanitize from .metrics import ( BaseMetric, CPUMetrics, DiskUsageMetrics, FanSpeedMetrics, + HardDriveMetrics, NetConnectionMetrics, NetworkMetrics, PackageUpdateMetrics, TempMetrics, VirtualMemoryMetrics, - HardDriveMetrics, ) from .type_definitions import Linux2MqttConfig, LinuxDeviceEntry main_logger = logging.getLogger("main") mqtt_logger = logging.getLogger("mqtt") + class Linux2Mqtt: """linux2mqtt class. diff --git a/linux2mqtt/metrics.py b/linux2mqtt/metrics.py index c89961c..4dbe2b6 100755 --- a/linux2mqtt/metrics.py +++ b/linux2mqtt/metrics.py @@ -23,10 +23,10 @@ Linux2MqttMetricsException, NoPackageManagerFound, ) +from .harddrive import HardDrive, get_hard_drive from .helpers import addr_ip, addr_port, is_addr, sanitize from .package_manager import PackageManager, get_package_manager from .type_definitions import LinuxDeviceEntry, LinuxEntry, MetricEntities, SensorType -from .harddrive import HardDrive, get_hard_drive metric_logger = logging.getLogger("metrics") @@ -1178,6 +1178,7 @@ def poll(self, result_queue: Queue[BaseMetric]) -> bool: th.start() return True # Expect a deferred result + class HardDriveMetricThread(BaseMetricThread): """Hard Drive metric thread.""" @@ -1211,7 +1212,6 @@ def run(self) -> None: """ try: - # self.harddrive.parse_attributes() self.metric.polled_result = { **self.harddrive.attributes, # type: ignore[unused-ignore] @@ -1221,7 +1221,8 @@ def run(self) -> None: raise Linux2MqttMetricsException( f"Could not gather and publish hard drive data {self.metric._name}" ) from ex - + + class HardDriveMetrics(BaseMetric): """Hard Drive metric.""" @@ -1256,10 +1257,8 @@ def __init__(self, device: str): raise Linux2MqttException( "Failed to find a suitable hard drive type. Currently supported are: Hard Disk and NVME" ) from ex - - - def poll(self, result_queue: Queue[Self]) -> bool: + def poll(self, result_queue: Queue[BaseMetric]) -> bool: """Poll new data for the hard drive metric. Parameters @@ -1286,11 +1285,8 @@ def poll(self, result_queue: Queue[Self]) -> bool: ) from e self.result_queue = result_queue th = HardDriveMetricThread( - result_queue=result_queue, - metric=self, - harddrive=self.harddrive - + result_queue=result_queue, metric=self, harddrive=self.harddrive ) th.daemon = True th.start() - return True # Expect a deferred result \ No newline at end of file + return True # Expect a deferred result From 3b374385fb1790592a9cc8c872644003573f7d0e Mon Sep 17 00:00:00 2001 From: Bimal Shah Date: Sat, 7 Feb 2026 22:18:09 -0500 Subject: [PATCH 18/20] Update type errors --- linux2mqtt/harddrive.py | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/linux2mqtt/harddrive.py b/linux2mqtt/harddrive.py index a34324a..ad2018c 100644 --- a/linux2mqtt/harddrive.py +++ b/linux2mqtt/harddrive.py @@ -87,22 +87,22 @@ def parse_attributes(self) -> None: ("UDMA CRC Error Count", 199), ] - self.attributes["Model Name"] = self._attributes["model_name"] # type: ignore - self.attributes["Device"] = self._attributes["device"]["name"] # type: ignore + self.attributes["Model Name"] = self._attributes["model_name"] # type: ignore[index] + self.attributes["Device"] = self._attributes["device"]["name"] # type: ignore[index] self.attributes["Size TB"] = ( - self._attributes["user_capacity"]["bytes"] / 1000000000000 # type: ignore - ) # type: ignore - self.attributes["Temperature"] = self._attributes["temperature"]["current"] # type: ignore + self._attributes["user_capacity"]["bytes"] / 1000000000000 # type: ignore[index] + ) # type: ignore[index] + self.attributes["Temperature"] = self._attributes["temperature"]["current"] # type: ignore[index] self.attributes["Smart status"] = ( - "Healthy" if self._attributes["smart_status"]["passed"] else "Failed" # type: ignore - ) # type: ignore - self.attributes["Power On Time"] = self._attributes["power_on_time"]["hours"] # type: ignore - self.attributes["Power Cycle Count"] = self._attributes["power_cycle_count"] # type: ignore + "Healthy" if self._attributes["smart_status"]["passed"] else "Failed" # type: ignore[index] + ) # type: ignore[index] + self.attributes["Power On Time"] = self._attributes["power_on_time"]["hours"] # type: ignore[index] + self.attributes["Power Cycle Count"] = self._attributes["power_cycle_count"] # type: ignore[index] new_data = { item["id"]: item - for item in self._attributes["ata_smart_attributes"]["table"] # type: ignore - } # type: ignore + for item in self._attributes["ata_smart_attributes"]["table"] # type: ignore[index] + } # type: ignore[index] for name, key in ata_smart_attributes: tmp = new_data[key]["raw"]["value"] if new_data.get(key) else None if tmp is not None: @@ -152,18 +152,18 @@ def parse_attributes(self) -> None: "available_spare_threshold", ] - self.attributes["Model Name"] = self._attributes["model_name"] # type: ignore - self.attributes["Device"] = self._attributes["device"]["name"] # type: ignore + self.attributes["Model Name"] = self._attributes["model_name"] # type: ignore[index] + self.attributes["Device"] = self._attributes["device"]["name"] # type: ignore[index] self.attributes["Size TB"] = ( - self._attributes["user_capacity"]["bytes"] / 1000000000000 # type: ignore - ) # type: ignore - self.attributes["Temperature"] = self._attributes["temperature"]["current"] # type: ignore + self._attributes["user_capacity"]["bytes"] / 1000000000000 # type: ignore[index] + ) # type: ignore[index] + self.attributes["Temperature"] = self._attributes["temperature"]["current"] # type: ignore[index] self.attributes["Smart status"] = ( - "Healthy" if self._attributes["smart_status"]["passed"] else "Failed" # type: ignore - ) # type: ignore + "Healthy" if self._attributes["smart_status"]["passed"] else "Failed" # type: ignore[index] + ) # type: ignore[index] for key in nvme_smart_attributes: - tmp = self._attributes["nvme_smart_health_information_log"].get(key) # type: ignore + tmp = self._attributes["nvme_smart_health_information_log"].get(key) # type: ignore[index] if tmp is not None: self.attributes[key] = tmp From 04de0a8f65f9b5eb0b2f31f69185572016a58116 Mon Sep 17 00:00:00 2001 From: Bimal Shah Date: Sun, 8 Feb 2026 23:06:02 -0500 Subject: [PATCH 19/20] Include scoring methodology into readme. --- README.md | 38 +++++++++++++++++++++++++++++++++++++- linux2mqtt/harddrive.py | 3 --- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b58d9c4..4023e82 100755 --- a/README.md +++ b/README.md @@ -120,10 +120,46 @@ Enabling this option will cause increased network traffic in order to update pac ### Hard Drives -`linux2mqtt` can publish the status of all harddrives using the `harddrives` option. Each hard drive will present as a separate sensor in Home Assistant. The sensor state reports the harddrive status based on a the smartctl report, which generates a score. Additional data is accessible as state attributes on each sensor. +`linux2mqtt` can publish the status of all harddrives using the `harddrives` option. Each hard drive will present as a separate sensor in Home Assistant. The sensor state reports the harddrive status based on a the smartctl report, which generates a score. The details on the scoring methodology can be found below. Additional data is accessible as state attributes on each sensor. `linux2mqtt --name Server1 -vvvvv --interval 60 --harddrives` +#### Scoring Methodology +The score to status conversion is: +| Status | Score | +|----|----| +| HEALTHY | <= 10 | +| GOOD | <= 20 | +| WARNING | <= 50 | +| FAILING | > 50 | + +##### ATA Scoring + +| SMART Attribute | Penalty | Notes | +| ----------------------------- | ------- | ------------------------------ | +| Reallocated Sector Count | Γ—2 | Indicates remapped bad sectors | +| Current Pending Sector | Γ—3 | Sectors waiting reallocation | +| Pending Sector > 10 | +30 | Additional penalty | +| Offline Uncorrectable | Γ—3 | Unrecoverable errors | +| Reported Uncorrectable Errors | Γ—2 | Read/write failures | +| Command Timeout | Γ—1.5 | Communication delays | +| UDMA CRC Error Count | max +10 | Usually cable/interface issue | + +##### NVME Scoring + +| SMART Attribute | Penalty | Notes | +| ------------------------------- | ------- | ------------------------------------------ | +| critical_warning β‰  0 | +100 | Any critical SMART flag triggers high risk | +| percent_used > 70% | +10 | NAND wear indicator | +| percent_used > 80% | +20 | Increased wear | +| percent_used > 90% | +50 | Near end-of-life | +| media_errors | Γ—5 | Data integrity errors | +| num_error_log_entries | max +50 | Error events (capped) | +| warning_temp_time > 0 | +10 | Drive exceeded warning temp | +| critical_temp_time > 0 | +30 | Drive exceeded critical temp | +| available_spare below threshold | +30 | Spare blocks depleted | + + ## 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. diff --git a/linux2mqtt/harddrive.py b/linux2mqtt/harddrive.py index ad2018c..01ba8c5 100644 --- a/linux2mqtt/harddrive.py +++ b/linux2mqtt/harddrive.py @@ -117,9 +117,6 @@ def get_score(self) -> None: """ATA Drive specific score function depending on results from smartctl.""" score = 0 score += self.attributes.get("Reallocated Sector Count", 0) * 2 - if self.attributes.get("Reallocated Sector Count", 0) > 50: - score += 50 - score += self.attributes.get("Current Pending Sector", 0) * 3 if self.attributes.get("Current Pending Sector", 0) > 10: score += 30 From ae94d5888a6c83b8255cb51e6085b4ba33f54240 Mon Sep 17 00:00:00 2001 From: Bimal Shah Date: Sun, 8 Feb 2026 23:14:30 -0500 Subject: [PATCH 20/20] Additional markdown cleaning --- README.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 4023e82..d2d3adf 100755 --- a/README.md +++ b/README.md @@ -125,17 +125,19 @@ Enabling this option will cause increased network traffic in order to update pac `linux2mqtt --name Server1 -vvvvv --interval 60 --harddrives` #### Scoring Methodology + The score to status conversion is: + | Status | Score | -|----|----| +| ------- | ----- | | HEALTHY | <= 10 | -| GOOD | <= 20 | +| GOOD | <= 20 | | WARNING | <= 50 | -| FAILING | > 50 | +| FAILING | > 50 | ##### ATA Scoring -| SMART Attribute | Penalty | Notes | +| SMART Attribute | Penalty | Notes | | ----------------------------- | ------- | ------------------------------ | | Reallocated Sector Count | Γ—2 | Indicates remapped bad sectors | | Current Pending Sector | Γ—3 | Sectors waiting reallocation | @@ -147,7 +149,7 @@ The score to status conversion is: ##### NVME Scoring -| SMART Attribute | Penalty | Notes | +| SMART Attribute | Penalty | Notes | | ------------------------------- | ------- | ------------------------------------------ | | critical_warning β‰  0 | +100 | Any critical SMART flag triggers high risk | | percent_used > 70% | +10 | NAND wear indicator | @@ -159,7 +161,6 @@ The score to status conversion is: | critical_temp_time > 0 | +30 | Drive exceeded critical temp | | available_spare below threshold | +30 | Spare blocks depleted | - ## 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.