diff --git a/.github/dependabot b/.github/dependabot deleted file mode 100644 index 5990d9c..0000000 --- a/.github/dependabot +++ /dev/null @@ -1,11 +0,0 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# Please see the documentation for all configuration options: -# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file - -version: 2 -updates: - - package-ecosystem: "" # See documentation for possible values - directory: "/" # Location of package manifests - schedule: - interval: "weekly" diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..a34b9d8 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + cooldown: + default-days: 7 + - package-ecosystem: pip + directories: + - / + - /library + schedule: + interval: weekly + cooldown: + default-days: 7 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 195ac75..224063b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -43,6 +43,8 @@ jobs: fail-fast: false matrix: include: + - language: javascript-typescript + build-mode: none - language: python build-mode: none # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift' diff --git a/.github/workflows/hassfest b/.github/workflows/hassfest deleted file mode 100644 index 07d1dda..0000000 --- a/.github/workflows/hassfest +++ /dev/null @@ -1,14 +0,0 @@ -name: Validate with hassfest - -on: - push: - pull_request: - schedule: - - cron: "0 0 * * *" - -jobs: - validate: - runs-on: "ubuntu-latest" - steps: - - uses: "actions/checkout@v3" - - uses: home-assistant/actions/hassfest@master diff --git a/.github/workflows/release-library.yml b/.github/workflows/release-library.yml new file mode 100644 index 0000000..b9387d1 --- /dev/null +++ b/.github/workflows/release-library.yml @@ -0,0 +1,48 @@ +name: Release aiosmartctl + +# Publishes the library to PyPI when a tag like "aiosmartctl-v0.1.0" is pushed. +# Uses PyPI trusted publishing: add this repository and workflow as a trusted +# publisher for the "aiosmartctl" project on pypi.org first. +on: + push: + tags: ["aiosmartctl-v*"] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + with: + python-version: "3.14" + - name: Check the tag matches the version + env: + TAG: ${{ github.ref_name }} + run: | + version=$(sed -n 's/^__version__ = "\(.*\)"/\1/p' library/src/aiosmartctl/__init__.py) + test "$TAG" = "aiosmartctl-v$version" + - run: uv build --out-dir dist library + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: dist + path: dist/ + + publish: + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/aiosmartctl/ + permissions: + id-token: write + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..3f097d3 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,74 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + library: + name: aiosmartctl (Python ${{ matrix.python }}) + runs-on: ubuntu-latest + strategy: + matrix: + python: ["3.13", "3.14"] + defaults: + run: + working-directory: library + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + with: + python-version: ${{ matrix.python }} + activate-environment: true + working-directory: library + - run: uv pip install -e ".[test]" mypy ruff + - run: ruff check . && ruff format --check . + - run: mypy --strict src + - run: pytest --cov --cov-report=term-missing --cov-fail-under=90 + + integration: + name: Integration + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + with: + python-version: "3.14" + activate-environment: true + - run: uv pip install -r requirements_test.txt ./library ruff + - run: ruff check custom_components tests script && ruff format --check custom_components tests script + - name: Translations are up to date + run: python script/gen_translations.py --check + - run: pytest --cov --cov-report=term-missing --cov-fail-under=100 + + core: + name: Home Assistant core checks (${{ matrix.platforms || 'all platforms' }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # The first core pull request may only contain one platform. + platforms: ["", "sensor"] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + with: + python-version: "3.14" + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: 22 + - name: Run core checks + env: + PLATFORMS: ${{ matrix.platforms }} + run: script/run_core_checks.sh "$RUNNER_TEMP/core-checks" diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..caf51cb --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,32 @@ +name: Validate + +on: + push: + branches: [main] + pull_request: + schedule: + - cron: "0 3 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + hassfest: + name: hassfest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: home-assistant/actions/hassfest@cd6f3635a740c84d8a14f6121a90068b8ece6bf6 # master + + hacs: + name: HACS + runs-on: ubuntu-latest + steps: + - uses: hacs/action@d556e736723344f83838d08488c983a15381059a # 22.5.0 + with: + category: integration + # Brand images are submitted to home-assistant/brands separately. + ignore: brands diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d083909 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +__pycache__/ +*.py[cod] +.coverage +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +*.egg-info/ +dist/ +build/ +.venv/ +venv/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fd7340f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,47 @@ +# Changelog + +## 2.0.0 + +A rewrite that prepares the integration for Home Assistant core. + +### Breaking changes + +- Requires Home Assistant 2026.10 or newer. +- Hosts added with 1.x must be confirmed again: Home Assistant asks for the + password or private key and shows the SSH host key to confirm. +- Disks are identified by WWN or serial number instead of their `/dev` path, + so all entity IDs change. Delete the old, unavailable entities. +- Health states are now `passed`, `warning` and `failed`. +- The **Download SMART Report** button was removed; use **Download + diagnostics**. Self-test errors are shown as errors instead of persistent + notifications. +- The scan interval is fixed at 5 minutes; disable polling and use + `homeassistant.update_entity` for another schedule. +- The server type option was removed; the host's operating system is detected. + +### New + +- SAS/SCSI disks: grown defects, uncorrected read/write/verify errors, + endurance of SAS SSDs. +- Disks behind RAID controllers (MegaRAID/PERC, and HP Smart Array, Areca, + 3ware and aacraid through the **Devices** option), found with + `smartctl --scan-open`. +- Sleeping disks are not woken up; a **Standby** binary sensor. +- Run smartctl through `sudo -n`. +- SSH host key pinning, private keys pasted in the UI, reauthentication and + reconfiguration flows. +- One device per host with its disks below it; disks that are gone can be + deleted. +- Endurance used, data read and written, NVMe available spare and more. +- **Self-test running** binary sensor and an **Abort self-test** button. +- Diagnostics. +- The `aiosmartctl` library and the SMART Monitor dashboard card. + +### Fixed + +- Options for password and key were saved but never used. +- A smartctl message containing "failed" marked the whole disk as unreadable. +- The binary sensor platform kept listening for new disks after the + integration was unloaded. +- Starting a self-test during a poll could break the poll, because both + shared one SSH connection object. diff --git a/README.md b/README.md index af44020..40754d5 100644 --- a/README.md +++ b/README.md @@ -1,282 +1,287 @@ -# SMART Disk Monitor – Home Assistant Integration - -Monitor SMART health data from all disks on remote servers (**Unraid**, **Proxmox**, **generic Linux**, **desktop Ubuntu**) directly in Home Assistant via SSH. - ---- +# SMART Disk Monitor for Home Assistant + +Monitor the SMART health of the disks in your servers from Home Assistant. +The integration logs in over SSH, runs +[smartmontools](https://www.smartmontools.org/) `smartctl` and creates a +device with sensors for every disk: health, temperature, wear, error counters +and self-tests. + +It works with Unraid, Proxmox VE, TrueNAS SCALE, Debian/Ubuntu and other +Linux or FreeBSD servers, including enterprise hardware: SAS disks, disks +behind MegaRAID/PERC and HP Smart Array controllers, and NVMe drives. + +> [!IMPORTANT] +> Version 2.0 is a rewrite that prepares the integration for inclusion in +> Home Assistant core. See [Upgrading from 1.x](#upgrading-from-1x). + +## Contents + +- [Features](#features) +- [Supported hardware and test status](#supported-hardware-and-test-status) +- [Requirements](#requirements) +- [Installation](#installation) +- [Configuration](#configuration) +- [Entities](#entities) +- [Dashboard card](#dashboard-card) +- [Automation examples](#automation-examples) +- [Security](#security) +- [Troubleshooting](#troubleshooting) +- [Upgrading from 1.x](#upgrading-from-1x) +- [Development](#development) ## Features -- ✅ Connects to remote servers via **SSH** (password or key-based auth) -- 🖴 Auto-discovers all block devices (`/dev/sda`, `/dev/nvme0n1`, …) -- 📊 Creates sensors for every disk: - - Health status (PASSED / FAILED / UNKNOWN) - - Temperature (°C) - - Power-on hours - - Power cycles - - Reallocated sectors *(HDD/SSD)* - - Pending sectors *(HDD/SSD)* - - Uncorrectable sectors *(HDD/SSD)* - - Available spare, % used, media errors *(NVMe)* - - Last self-test result and date -- 🔘 Buttons per disk: - - **Run Short Self-Test** (~2 min) - - **Run Long Self-Test** (thorough, hours) - - **Run Conveyance Self-Test** (~5 min) - - **Download SMART Report** (saves full report to `/config/smart_reports/`) -- 🔔 Binary sensors for instant alerts: - - **Health Problem** – fires when `smartctl` reports FAILED - - **Sector Problem** – fires when bad/pending/uncorrectable sectors > 0 -- Supports **HDD**, **SSD**, and **NVMe** drives -- Configurable poll interval (default: 5 minutes) -- Multiple servers supported (add one entry per server) - ---- +- One device per host, with one device per disk underneath it +- Finds disks with `smartctl --scan-open`, including disks behind RAID + controllers, and falls back to `lsblk` +- Disks smartctl cannot scan, such as disks behind an HP Smart Array, can be + listed by hand (`/dev/sg0 cciss,0`) +- Does not wake up sleeping disks: their last values are kept and a + **Standby** sensor shows they are asleep +- Start and abort short, long and conveyance self-tests +- Works with a non-root user through `sudo -n smartctl` +- Password or private key login; the SSH host key is confirmed when the host + is added and checked on every connection +- Diagnostics download with serial numbers and credentials removed +- Reauthentication when the password or host key changes, and + reconfiguration of the address, port, user and sudo setting + +## Supported hardware and test status + +> [!WARNING] +> **Not every connection type has been tested against real hardware.** +> Every disk type below is covered by automated tests that run the parsers on +> sample `smartctl` output and the SSH client against a simulated SSH server. +> That proves the code handles the documented output formats, not that every +> controller, firmware or operating system produces exactly that output. +> Version 2.0 has **not yet been run against real hardware** at all; the 1.x +> releases were used on Unraid, Proxmox VE and Ubuntu with SATA and NVMe +> disks. +> +> If you run it on hardware marked untested, please +> [open an issue](https://github.com/ChristianHandy/HAOS_SMART_Monitor/issues) +> with the result and, if something is wrong, the output of +> `smartctl --json -a ` (remove serial numbers if you like). + +| Hardware or setup | How smartctl reaches it | Automated tests | Tested on real hardware | +|---|---|---|---| +| SATA hard disk | `-d sat` | ✅ | ⚠️ 1.x only | +| SATA SSD | `-d sat` | ✅ | ⚠️ 1.x only | +| NVMe SSD | `-d nvme` | ✅ | ⚠️ 1.x only | +| SAS hard disk or SSD | `-d scsi` | ✅ | ❌ Not yet | +| Disk behind LSI MegaRAID / Dell PERC | `-d megaraid,N` | ✅ | ❌ Not yet | +| Disk behind HP Smart Array (manual) | `-d cciss,N` | ✅ (parsing) | ❌ Not yet | +| Areca, 3ware, Adaptec (aacraid) | `-d areca,N`, `-d 3ware,N`, `-d aacraid,…` | ⚠️ Same code path, no sample output | ❌ Not yet | +| USB enclosure / bridge | `-d sat` and others | ✅ (no SMART reported) | ❌ Not yet | +| Multipath SAS (one disk, two paths) | — | ✅ (shown once) | ❌ Not yet | +| smartctl 6.x without JSON | text output | ✅ | ❌ Not yet | +| Unraid, Proxmox VE, Debian/Ubuntu host | SSH | ✅ (simulated) | ⚠️ 1.x only | +| TrueNAS SCALE, Synology DSM, FreeBSD / TrueNAS CORE | SSH | ✅ (simulated) | ❌ Not yet | +| Password, private key, sudo | SSH | ✅ (simulated SSH server) | ❌ Not yet | + +Hardware RAID volumes themselves (for example `/dev/sda` on a PERC) are not +disks and report no SMART data; the physical disks behind the controller are +shown instead when the controller supports pass-through. ## Requirements -### Home Assistant side -- Home Assistant **2023.6+** -- Python package `paramiko` (installed automatically via `requirements`) - -### Remote server side -- SSH access enabled -- `smartmontools` installed (see per-OS instructions below) - ---- - -## Server Setup by OS - -> ⚠️ **This is the most important section.** Skipping these steps is the most common reason sensors show "Unknown". - -### Unraid - -smartmontools is pre-installed and SSH runs as `root` — no extra setup needed. - -``` -Username: root -Password: your Unraid root password -``` - -### Proxmox - -smartmontools is pre-installed and SSH runs as `root` — no extra setup needed. - -``` -Username: root -Password: your Proxmox root password -``` - -### Debian / Ubuntu Server (headless) - -```bash -# Install smartmontools -apt install smartmontools - -# Option A — connect as root (simplest) -# Make sure PermitRootLogin is enabled in /etc/ssh/sshd_config - -# Option B — connect as a regular user, grant sudo access to smartctl -echo 'YOUR_USER ALL=(ALL) NOPASSWD: /usr/sbin/smartctl' > /etc/sudoers.d/smart-monitor -chmod 440 /etc/sudoers.d/smart-monitor -``` - -### Ubuntu Desktop (most involved — read carefully) - -Ubuntu Desktop has **AppArmor** enabled and regular users are not in the `disk` group by default. Both issues must be fixed for smartctl to access disk devices over SSH. - -**Step 1 — Install smartmontools** -```bash -sudo apt install smartmontools -``` - -**Step 2 — Add your SSH user to the `disk` group** -```bash -sudo usermod -aG disk YOUR_USERNAME -``` - -**Step 3 — Fix AppArmor** so smartctl can open block devices in SSH sessions -```bash -sudo aa-complain /usr/sbin/smartctl -``` - -Or create a local override (more precise than complain mode): -```bash -sudo mkdir -p /etc/apparmor.d/local -cat << 'AAEOF' | sudo tee /etc/apparmor.d/local/usr.sbin.smartctl -/dev/sd* rw, -/dev/nvme* rw, -/dev/hd* rw, -AAEOF -sudo apparmor_parser -r /etc/apparmor.d/usr.sbin.smartctl 2>/dev/null || true -``` - -**Step 4 — Reboot** - -> ⚠️ Group membership changes **require a full reboot** to take effect for SSH sessions. A logout/login is not enough. +**Home Assistant** 2026.10 or newer. -```bash -sudo reboot -``` +**On every monitored host:** -**Step 5 — Verify it works** -```bash -ssh YOUR_USERNAME@localhost "groups | grep disk && smartctl -a /dev/sda 2>&1 | head -5" -``` +- An SSH server you can log in to with a password or a private key +- smartmontools 7.0 or newer (6.x works with fewer details) +- Either the `root` user, or a user that may run smartctl with + `sudo` without a password: -You should see `disk` in the groups line and smartctl showing disk info — not "Permission denied". + ```bash + sudo apt install smartmontools + echo 'ha-smart ALL=(root) NOPASSWD: /usr/sbin/smartctl' | sudo tee /etc/sudoers.d/ha-smart + sudo chmod 440 /etc/sudoers.d/ha-smart + ``` -**Why is this needed?** + Then enable **Run smartctl with sudo** when you add the host. -When you run `smartctl` interactively in a terminal it works because your desktop session already has the `disk` group active. SSH sessions start fresh and only get the groups that were active at last login — which is why a reboot (not just logout) is required after `usermod -aG disk`. +Unraid and Proxmox VE ship smartmontools and log in as `root`, so they need +no preparation. -AppArmor additionally restricts smartctl from opening raw block devices in non-interactive sessions. `aa-complain` switches it from blocking to logging-only. +## Installation ---- +### HACS -## Installation +1. In HACS, open the menu → **Custom repositories**, add + `https://github.com/ChristianHandy/HAOS_SMART_Monitor` with type + **Integration**. +2. Install **SMART Disk Monitor** and restart Home Assistant. ### Manual -``` -config/ -└── custom_components/ - └── smart_monitor/ ← copy this folder - ├── __init__.py - ├── manifest.json - ├── config_flow.py - ├── coordinator.py - ├── const.py - ├── smart_fetcher.py - ├── sensor.py - ├── binary_sensor.py - ├── button.py - ├── strings.json - └── translations/ - └── en.json -``` +Copy `custom_components/smart_monitor` into the `custom_components` folder +of your Home Assistant configuration and restart Home Assistant. -Restart Home Assistant after copying. +## Configuration ---- +1. Go to **Settings → Devices & services → Add integration** and pick + **SMART Disk Monitor**. +2. Enter the host, SSH port, user name and a password or a private key + (paste the whole key, starting with `-----BEGIN`). +3. Compare the host key fingerprint with the one on the host and confirm it: -## Configuration + ```bash + ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub + ``` -1. **Settings → Devices & Services → Add Integration** → search *SMART Disk Monitor* -2. Fill in: +Add the integration once per host. -| Field | Example | Notes | -|---|---|---| -| Hostname / IP | `192.168.1.10` | IP or hostname of the remote machine | -| SSH Port | `22` | Default for all OS types | -| Username | `christian` | The SSH user you set up above | -| Password | `••••` | Or leave blank if using a key | -| SSH Key Path | `/config/ssh/id_ed25519` | Path *inside the HA container* | -| Server Type | `generic_linux` | Label only, used for display | -| Scan Interval | `300` | Seconds; 300 = 5 minutes | +### Options -3. Click **Submit** — HA will test the SSH connection. -4. Repeat for each server. +| Option | Default | Description | +|---|---|---| +| Do not wake up sleeping disks | On | Keeps the last values of disks in standby instead of spinning them up. | +| Devices | Empty | Disks to read instead of discovering them, one per line: a device path, optionally followed by a smartctl device type, for example `/dev/bus/0 megaraid,3` or `/dev/sg0 cciss,1`. | ---- +SMART data is read every 5 minutes. To read it on your own schedule, turn off +polling in the integration's system options and call +`homeassistant.update_entity` from an automation. -## Entities Created +## Entities -For a server `192.168.1.10` with disk `/dev/sda`: +Entities are created for the values a disk reports, so an NVMe SSD has no +reallocated sector sensor and a SAS disk has grown defects instead. -| Entity | Type | Description | +| Entity | Disks | Notes | |---|---|---| -| `sensor.…_health` | sensor | PASSED / FAILED / UNKNOWN | -| `sensor.…_temperature` | sensor | °C | -| `sensor.…_power_on_hours` | sensor | Hours | -| `sensor.…_power_cycles` | sensor | Count | -| `sensor.…_reallocated_sectors` | sensor | Count (HDD/SSD) | -| `sensor.…_pending_sectors` | sensor | Count (HDD/SSD) | -| `sensor.…_uncorrectable_sectors` | sensor | Count (HDD/SSD) | -| `sensor.…_last_self_test` | sensor | Last test result + date | -| `binary_sensor.…_health_problem` | binary | On = FAILED | -| `binary_sensor.…_sector_problem` | binary | On = any bad sectors | -| `button.…_run_short_self_test` | button | Starts short test | -| `button.…_run_long_self_test` | button | Starts long test | -| `button.…_run_conveyance_self_test` | button | Starts conveyance test | -| `button.…_download_smart_report` | button | Saves full report to `/config/smart_reports/` | - ---- - -## Automation Examples - -### Alert when any disk fails health check +| Health | all | `passed`, `warning` (an attribute crossed its threshold, NVMe critical warning) or `failed` | +| Temperature | all | °C | +| Endurance used | SSD, NVMe, SAS SSD | % of the rated endurance | +| Power-on time, Power cycles | all | diagnostic | +| Last self-test | disks with self-tests | `passed`, `failed`, `aborted` | +| Data read / written | NVMe, SAS, SSDs that report it | diagnostic | +| Reallocated, pending and offline uncorrectable sectors, interface CRC errors | SATA | diagnostic | +| Available spare, media errors | NVMe | diagnostic | +| Grown defects, uncorrected read/write errors | SAS | diagnostic | +| Command timeouts, spin retries, unsafe shutdowns, error log entries and more | varies | diagnostic, disabled by default | +| Health problem, Bad sectors | binary sensors | `on` when there is a problem | +| Self-test running, Standby | binary sensors | diagnostic | +| Start short / long / conveyance self-test, Abort self-test | buttons | only the tests the disk supports | + +## Dashboard card + +The [SMART Monitor card](card/README.md) shows all disks grouped by host with +their health, temperature, wear and error counters, and can start +self-tests. ```yaml -automation: - alias: "Disk SMART failure alert" - trigger: - - platform: state - entity_id: - - binary_sensor.nas_dev_sda_health_problem - - binary_sensor.nas_dev_sdb_health_problem - to: "on" - action: - - service: notify.mobile_app_my_phone - data: - title: "⚠️ Disk Health Alert" - message: "{{ trigger.to_state.name }} is reporting a SMART failure!" +type: custom:smart-monitor-card +title: Disks ``` -### Alert on high disk temperature +## Automation examples + +Notify when any disk reports a problem: ```yaml -automation: - alias: "Disk overheating alert" - trigger: - - platform: numeric_state - entity_id: sensor.nas_dev_sda_temperature - above: 55 - action: - - service: notify.notify - data: - message: "Disk /dev/sda is {{ states('sensor.nas_dev_sda_temperature') }}°C!" +triggers: + - trigger: state + entity_id: + - binary_sensor.wdc_wd40efrx_68n32n0_health_problem + - binary_sensor.wdc_wd40efrx_68n32n0_bad_sectors + to: "on" +actions: + - action: notify.mobile_app_phone + data: + title: Disk problem + message: "{{ trigger.to_state.name }} on {{ device_attr(device_id(trigger.entity_id), 'name') }}" ``` ---- - -## Troubleshooting - -### Sensors all show "Unknown" / "Unbekannt" +Run a long self-test every month: -This means the SSH connection works but `smartctl` cannot open the disk device. Check the HA logs (*Settings → System → Logs*, filter `smart_monitor`) for the exact error. +```yaml +triggers: + - trigger: time + at: "03:00:00" +conditions: + - condition: template + value_template: "{{ now().day == 1 }}" +actions: + - action: button.press + target: + entity_id: button.wdc_wd40efrx_68n32n0_start_long_self_test +``` -**"Permission denied" on the device** +## Security -Follow the full [Ubuntu Desktop setup](#ubuntu-desktop-most-involved--read-carefully) section above. The two most common causes are: -- User not in the `disk` group → `sudo usermod -aG disk YOUR_USER` + reboot -- AppArmor blocking SSH sessions → `sudo aa-complain /usr/sbin/smartctl` +- The SSH host key is shown when a host is added and pinned. A different key + later stops the integration and asks you to confirm the new key, so a + machine pretending to be your server never receives your password. +- Credentials are stored in Home Assistant's config entry storage, like the + credentials of every other integration. +- Use a dedicated user with sudo rights for smartctl only, see + [Requirements](#requirements). +- Commands are run with all arguments quoted. See [SECURITY.md](SECURITY.md) + for the full list of commands. -**smartctl not found** +## Troubleshooting -```bash -which smartctl || apt install smartmontools +**"smartctl was not found"**: install smartmontools. On some NAS systems +smartctl is in `/usr/sbin` or `/usr/local/sbin`; both are searched. + +**Disks are unavailable with "Permission denied"**: log in as root, or enable +sudo as described in [Requirements](#requirements). + +**"smartctl could not be run with sudo"**: sudo asked for a password. Add the +`NOPASSWD` rule shown above. + +**A disk behind a RAID controller is missing**: run +`smartctl --scan-open` on the host. If the disk is not listed, add it under +**Devices** in the options, for example `/dev/sg0 cciss,0`. + +**"The SSH host key changed"**: if you reinstalled the host or regenerated +its keys, open the reauthentication prompt on **Settings → Devices & +services** and confirm the new fingerprint. Otherwise investigate before +continuing. + +**Download diagnostics** from the integration page when reporting a problem. + +## Upgrading from 1.x + +- Hosts added with 1.x ask you to re-enter the password or paste the private + key and to confirm the host key. Key files referenced by path are no longer + used. +- Disks are now identified by WWN or serial number (or, if a disk reports + neither, by host and device path) instead of `/dev/sdX`, so + entity IDs change. The old entities show as unavailable; delete them from + **Settings → Entities**. +- Health states are now `passed`, `warning` and `failed` (were `PASSED` and + `FAILED`). Update automations that compare them. +- The **Download SMART Report** button was removed. Use **Download + diagnostics** on the integration page. +- The scan interval can no longer be configured; see [Options](#options). + +## Development + +```text +custom_components/smart_monitor/ the integration +library/ aiosmartctl, the SSH/smartctl library on PyPI +card/ the dashboard card +tests/ integration tests +docs/core/ files for the Home Assistant core submission +script/ translation, core port and check scripts ``` -### Cannot connect at all - ```bash -# Test SSH manually from another machine -ssh YOUR_USER@192.168.1.x - -# Check SSH is running -systemctl status ssh +pip install -r requirements_test.txt ./library +pytest # integration tests +cd library && pip install -e ".[test]" && pytest # library tests +python script/gen_translations.py # after changing strings.json +script/run_core_checks.sh # Home Assistant core's own checks ``` -### Buttons not appearing - -Buttons appear under **"Steuerungen"** (Controls) on the device page, not under "Sensoren". If still missing, go to **Settings → Devices & Services → SMART Disk Monitor → ⋮ → Reload**. - -### SSH key not found - -The key path must be accessible from *within the Home Assistant container*. Store it under `/config/` and reference it as `/config/ssh/id_ed25519`. - ---- +`script/run_core_checks.sh` copies the integration into a Home Assistant core +checkout and runs hassfest, ruff, codespell, prettier, yamllint, strict mypy, +pylint and pytest with 100% coverage, as core's CI does. See +[docs/CORE_SUBMISSION.md](docs/CORE_SUBMISSION.md) for the path into core. ## License -MIT – see `LICENSE` +MIT, see [LICENSE](LICENSE). diff --git a/SECURITY.md b/SECURITY.md index e9e3dfa..bcf27a9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,116 +1,85 @@ # Security Policy -## Supported Versions +## Supported versions -Only the latest release receives security fixes. Please update to the latest version before reporting a vulnerability. +Only the latest release receives security fixes. | Version | Supported | |---------|-----------| -| 1.2.x | ✅ Yes | -| < 1.2 | ❌ No | +| 2.x | ✅ Yes | +| < 2.0 | ❌ No | ---- - -## Reporting a Vulnerability +## Reporting a vulnerability **Please do not report security vulnerabilities through public GitHub issues.** -Instead, open a [GitHub Security Advisory](../../security/advisories/new) or send a private message to the repository owner. Include as much detail as possible: +Open a [GitHub security advisory](../../security/advisories/new) instead. +Include a description, steps to reproduce, the impact and, if you have one, a +suggested fix. You can expect an acknowledgement within 48 hours and a fix or +mitigation plan within 7 days for critical issues. -- A description of the vulnerability -- Steps to reproduce -- Potential impact -- Suggested fix (if any) +## Security model -You can expect an acknowledgement within **48 hours** and a fix or mitigation plan within **7 days** for critical issues. +The integration logs in to your servers over SSH and runs smartctl. ---- +### Host key verification -## Security Considerations +When a host is added, the integration fetches its SSH host key without +logging in and shows the SHA256 fingerprint. Credentials are only sent after +you confirm it. The key is stored with the config entry and every later +connection must present the same key; if it changes, the integration stops +and starts reauthentication, which shows the new fingerprint and a warning. -This integration connects Home Assistant to remote machines over SSH. Understanding the security model helps you deploy it safely. +Versions before 2.0 accepted any host key (`AutoAddPolicy`). -### SSH Credentials +### Credentials -- Credentials (username, password, or SSH key path) are stored in Home Assistant's config entry storage at `/config/.storage/core.config_entries` -- This file is readable by anyone with access to your HA filesystem — **secure your HA instance accordingly** -- Prefer **SSH key authentication** over passwords where possible -- Use a **dedicated low-privilege user** with only the permissions needed (see below) rather than `root` +- The password or private key is stored in Home Assistant's config entry + storage (`/config/.storage/core.config_entries`), like the credentials of + every other integration. Protect access to your Home Assistant files. +- Private keys must be unencrypted; use a key made for this purpose. +- The SSH agent, `~/.ssh/config` and default key files of the Home Assistant + host are never used. +- Diagnostics remove the host, user name, password, private key, host key, + serial numbers and WWNs. -### Principle of Least Privilege +### Least privilege -The integration only needs to run `smartctl` on the remote machine. You can lock it down to exactly that: +Prefer a dedicated user that may only run smartctl through sudo: ```bash -# Create a dedicated user -useradd -r -s /bin/bash ha-smart - -# Allow only smartctl — nothing else -echo 'ha-smart ALL=(ALL) NOPASSWD: /usr/sbin/smartctl' > /etc/sudoers.d/ha-smart +useradd -r -m -s /bin/sh ha-smart +echo 'ha-smart ALL=(root) NOPASSWD: /usr/sbin/smartctl' > /etc/sudoers.d/ha-smart chmod 440 /etc/sudoers.d/ha-smart - -# Add to disk group (alternative to sudo on some systems) -usermod -aG disk ha-smart -``` - -Use `ha-smart` as the username in the HA integration config instead of `root`. - -### SSH Key Hardening - -If using key-based authentication, restrict what the key can do on the server side: - -``` -# ~/.ssh/authorized_keys on the remote server -restrict,command="/usr/sbin/smartctl" ssh-ed25519 AAAA... home-assistant ``` -> Note: The `command=` restriction limits the key to a single command. This is the most secure option but means the integration cannot run `lsblk` to auto-discover disks — you would need to manually specify devices. For most home users the disk group + sudo approach is a reasonable balance. +and enable **Run smartctl with sudo**. smartctl can start and abort +self-tests, which the integration offers as buttons; it cannot write data. -### What the Integration Does Over SSH +### Commands run on the host -The integration executes only these commands on the remote machine: +Every command runs through `sh -c` with all arguments quoted. Device paths +and types come from `smartctl --scan-open`, `lsblk`, or the **Devices** +option, which only accepts a path plus a device type made of letters, digits +and `,+_-`. -| Command | Purpose | +| Command | When | |---|---| -| `lsblk -d -o NAME,TYPE --json` | Discover block devices | -| `smartctl -a --json=c /dev/sdX` | Read SMART data | -| `smartctl -a /dev/sdX` | Fallback text read | -| `smartctl -t short\|long\|conveyance /dev/sdX` | Start a self-test (button press only) | -| `smartctl -x /dev/sdX` | Full report (button press only) | -| `which smartctl` | Locate smartctl binary | - -No files are written to the remote machine. No other commands are executed. - -### Network Security - -- All communication is encrypted via SSH -- The integration uses `paramiko` and accepts the remote host key on first connection (`AutoAddPolicy`) — this is convenient but means the first connection is vulnerable to a man-in-the-middle attack on untrusted networks -- For production use on untrusted networks, manually add the host key to your HA `known_hosts` file and switch to `RejectPolicy` - -### Report Saved to Disk - -When the **Download SMART Report** button is pressed, the full `smartctl -x` output is saved to `/config/smart_reports/` inside the HA container. These files may contain: - -- Disk serial numbers -- Firmware versions -- Full SMART attribute history - -Treat these files as sensitive and do not share them publicly. The `/config/smart_reports/` directory is not exposed by default but is accessible via the HA file editor and Samba shares if those are enabled. - ---- - -## Known Limitations - -- **Host key verification** is currently set to `AutoAddPolicy` (trust on first connect). A future version will support strict host key checking. -- **Credentials are stored in plaintext** within HA's config storage. This is consistent with how all HA integrations store credentials and is protected by HA's overall security model. -- The integration has **no rate limiting** on button presses — running a long self-test repeatedly could theoretically stress a degraded disk. Use the long self-test button with care on disks already showing errors. +| `hostname`, `uname -s`, `uname -r`, `cat /etc/os-release`, `/etc/unraid-version`, `pveversion` | Once at setup, to name the host | +| `smartctl --version` | Setup | +| `smartctl --scan-open --json=c` | Every update, unless devices are set | +| `lsblk -d -J -o NAME,TYPE` | When the scan finds nothing | +| `smartctl --json=c -a [-n standby] [-d TYPE] DEVICE` | Every update, per disk | +| `smartctl -t short\|long\|conveyance [-d TYPE] DEVICE` | Self-test button | +| `smartctl -X [-d TYPE] DEVICE` | Abort self-test button | ---- +With sudo enabled, `smartctl` is run as `sudo -n smartctl`. Nothing is +written to the host. -## Changelog of Security-Relevant Changes +## Security-relevant changes | Version | Change | |---|---| -| 1.2.0 | Removed `ssh://` from device registry (was rejected by HA, no security impact) | -| 1.1.x | Fixed `via_device` references that caused silent entity registration failures | +| 2.0.0 | SSH host keys are confirmed and pinned; credentials are only sent to a confirmed host. Private keys are stored instead of read from a path. Commands are quoted. The report button that wrote files to `/config/smart_reports/` was removed. | +| 1.2.0 | Removed `ssh://` from the device registry | | 1.0.0 | Initial release | diff --git a/__init__.py b/__init__.py deleted file mode 100644 index 9e5dc6f..0000000 --- a/__init__.py +++ /dev/null @@ -1,66 +0,0 @@ -"""SMART Disk Monitor integration for Home Assistant.""" -from __future__ import annotations - -import logging - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant - -from .const import ( - DOMAIN, - CONF_HOST, - CONF_PORT, - CONF_USERNAME, - CONF_PASSWORD, - CONF_SSH_KEY, - CONF_SERVER_TYPE, - CONF_SCAN_INTERVAL, - DEFAULT_PORT, - DEFAULT_SCAN_INTERVAL, -) -from .coordinator import SmartMonitorCoordinator -from .smart_fetcher import SmartDataFetcher - -_LOGGER = logging.getLogger(__name__) - -PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.BINARY_SENSOR, Platform.BUTTON] - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up SMART Disk Monitor from a config entry.""" - _LOGGER.debug("smart_monitor: async_setup_entry called, platforms=%s", PLATFORMS) - data = entry.data - - fetcher = SmartDataFetcher( - host=data[CONF_HOST], - port=data.get(CONF_PORT, DEFAULT_PORT), - username=data[CONF_USERNAME], - password=data.get(CONF_PASSWORD), - ssh_key_path=data.get(CONF_SSH_KEY), - server_type=data.get(CONF_SERVER_TYPE, "generic_linux"), - ) - - coordinator = SmartMonitorCoordinator( - hass=hass, - entry_id=entry.entry_id, - fetcher=fetcher, - server_name=data[CONF_HOST], - scan_interval=data.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL), - ) - - await coordinator.async_config_entry_first_refresh() - - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator - - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - - return True - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok diff --git a/binary_sensor.py b/binary_sensor.py deleted file mode 100644 index 0ae7638..0000000 --- a/binary_sensor.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Binary sensor platform for SMART Disk Monitor – disk health alerts.""" -from __future__ import annotations - -from homeassistant.components.binary_sensor import ( - BinarySensorDeviceClass, - BinarySensorEntity, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .const import DOMAIN, CONF_HOST, CONF_SERVER_TYPE, HEALTH_FAILED -from .coordinator import SmartMonitorCoordinator -from .smart_fetcher import DiskSmartData -from .sensor import _device_display_name, _manufacturer_from_model - - -async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - coordinator: SmartMonitorCoordinator = hass.data[DOMAIN][entry.entry_id] - host = entry.data[CONF_HOST] - server_type = entry.data.get(CONF_SERVER_TYPE, "generic_linux") - - entities = [] - devices = list(coordinator.data.keys()) if coordinator.data else [] - for device in devices: - entities.append(DiskHealthBinarySensor(coordinator, entry, device, host, server_type)) - entities.append(DiskProblemBinarySensor(coordinator, entry, device, host, server_type)) - - async_add_entities(entities, True) - - _known: set[str] = set(devices) - - def _check_new_devices() -> None: - nonlocal _known - if not coordinator.data: - return - new_devices = set(coordinator.data.keys()) - _known - if not new_devices: - return - _known.update(new_devices) - new_ents = [] - for dev in new_devices: - new_ents.append(DiskHealthBinarySensor(coordinator, entry, dev, host, server_type)) - new_ents.append(DiskProblemBinarySensor(coordinator, entry, dev, host, server_type)) - async_add_entities(new_ents, True) - - coordinator.async_add_listener(_check_new_devices) - - -class _DiskBinarySensorBase(CoordinatorEntity[SmartMonitorCoordinator], BinarySensorEntity): - _attr_has_entity_name = True - - def __init__(self, coordinator, entry, device, host, server_type): - super().__init__(coordinator) - self._device = device - self._host = host - self._server_type = server_type - self._dev_slug = device.replace("/dev/", "").replace("/", "_") - - @property - def _disk_data(self) -> DiskSmartData | None: - if self.coordinator.data: - return self.coordinator.data.get(self._device) - return None - - @property - def device_info(self) -> DeviceInfo: - disk = self._disk_data - name = _device_display_name(disk, self._device) - manufacturer = _manufacturer_from_model(disk.model if disk else "Unknown") - return DeviceInfo( - identifiers={(DOMAIN, f"{self._host}_{self._dev_slug}")}, - name=name, - manufacturer=manufacturer, - model=disk.model if disk else None, - serial_number=disk.serial if (disk and disk.serial != "Unknown") else None, - ) - - @property - def available(self) -> bool: - disk = self._disk_data - return disk is not None and disk.error is None - - -class DiskHealthBinarySensor(_DiskBinarySensorBase): - """True when SMART health test FAILED.""" - - _attr_device_class = BinarySensorDeviceClass.PROBLEM - - def __init__(self, coordinator, entry, device, host, server_type): - super().__init__(coordinator, entry, device, host, server_type) - self._attr_unique_id = f"{host}_{self._dev_slug}_health_problem" - self._attr_name = "Health Problem" - self._attr_icon = "mdi:harddisk-remove" - - @property - def is_on(self) -> bool | None: - disk = self._disk_data - if disk is None: - return None - return disk.health == HEALTH_FAILED - - -class DiskProblemBinarySensor(_DiskBinarySensorBase): - """True when reallocated, pending, or uncorrectable sector count > 0.""" - - _attr_device_class = BinarySensorDeviceClass.PROBLEM - - def __init__(self, coordinator, entry, device, host, server_type): - super().__init__(coordinator, entry, device, host, server_type) - self._attr_unique_id = f"{host}_{self._dev_slug}_sector_problem" - self._attr_name = "Sector Problem" - self._attr_icon = "mdi:alert-circle" - - @property - def is_on(self) -> bool | None: - disk = self._disk_data - if disk is None: - return None - bad = ( - (disk.reallocated_sectors or 0) - + (disk.pending_sectors or 0) - + (disk.uncorrectable_sectors or 0) - ) - return bad > 0 - - @property - def extra_state_attributes(self) -> dict: - disk = self._disk_data - if not disk: - return {} - return { - "reallocated_sectors": disk.reallocated_sectors, - "pending_sectors": disk.pending_sectors, - "uncorrectable_sectors": disk.uncorrectable_sectors, - } diff --git a/button.py b/button.py deleted file mode 100644 index 555136e..0000000 --- a/button.py +++ /dev/null @@ -1,251 +0,0 @@ -"""Button platform for SMART Disk Monitor.""" -from __future__ import annotations - -import logging -import os -from datetime import datetime - -from homeassistant.components.button import ButtonEntity -from homeassistant.components.persistent_notification import async_create as notify_create -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .const import DOMAIN, CONF_HOST, CONF_SERVER_TYPE -from .coordinator import SmartMonitorCoordinator -from .sensor import _device_display_name, _manufacturer_from_model - -_LOGGER = logging.getLogger(__name__) - -REPORTS_DIR = "/config/smart_reports" - - -async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - coordinator: SmartMonitorCoordinator = hass.data[DOMAIN][entry.entry_id] - host = entry.data[CONF_HOST] - server_type = entry.data.get(CONF_SERVER_TYPE, "generic_linux") - - _LOGGER.debug( - "smart_monitor button setup_entry called. coordinator.data=%s", - list(coordinator.data.keys()) if coordinator.data else None, - ) - - def _make_buttons(device: str) -> list[ButtonEntity]: - _LOGGER.debug("Creating buttons for device %s on %s", device, host) - return [ - RunTestButton(coordinator, entry, device, host, server_type, "short"), - RunTestButton(coordinator, entry, device, host, server_type, "long"), - RunTestButton(coordinator, entry, device, host, server_type, "conveyance"), - DownloadReportButton(coordinator, entry, device, host, server_type), - ] - - entities: list[ButtonEntity] = [] - devices = list(coordinator.data.keys()) if coordinator.data else [] - for device in devices: - entities.extend(_make_buttons(device)) - - _LOGGER.debug("smart_monitor: adding %d button entities", len(entities)) - async_add_entities(entities) - - # Dynamically add buttons for disks that appear after the first poll - _known: set[str] = set(devices) - - def _on_coordinator_update() -> None: - nonlocal _known - if not coordinator.data: - return - new_devices = set(coordinator.data.keys()) - _known - if not new_devices: - return - _known.update(new_devices) - new_buttons: list[ButtonEntity] = [] - for dev in new_devices: - new_buttons.extend(_make_buttons(dev)) - _LOGGER.debug("smart_monitor: dynamically adding %d button entities", len(new_buttons)) - async_add_entities(new_buttons) - - entry.async_on_unload(coordinator.async_add_listener(_on_coordinator_update)) - - -# --------------------------------------------------------------------------- -# Base -# --------------------------------------------------------------------------- - -class _DiskButtonBase(CoordinatorEntity[SmartMonitorCoordinator], ButtonEntity): - """Base button entity.""" - - _attr_has_entity_name = True - - def __init__( - self, - coordinator: SmartMonitorCoordinator, - entry: ConfigEntry, - device: str, - host: str, - server_type: str, - ) -> None: - super().__init__(coordinator) - self._entry = entry - self._device = device - self._host = host - self._server_type = server_type - self._dev_slug = device.replace("/dev/", "").replace("/", "_") - - @property - def available(self) -> bool: - return True - - @property - def device_info(self) -> DeviceInfo: - disk_data = self.coordinator.data.get(self._device) if self.coordinator.data else None - name = _device_display_name(disk_data, self._device) - manufacturer = _manufacturer_from_model(disk_data.model if disk_data else "Unknown") - return DeviceInfo( - identifiers={(DOMAIN, f"{self._host}_{self._dev_slug}")}, - name=name, - manufacturer=manufacturer, - ) - - -# --------------------------------------------------------------------------- -# Run Self-Test button -# --------------------------------------------------------------------------- - -_TEST_LABELS: dict[str, tuple[str, str]] = { - "short": ("Run Short Self-Test", "mdi:play-circle-outline"), - "long": ("Run Long Self-Test", "mdi:play-circle"), - "conveyance": ("Run Conveyance Self-Test", "mdi:truck-check"), -} - -_TEST_DURATION: dict[str, str] = { - "short": "~2 minutes", - "long": "hours (varies by disk size)", - "conveyance": "~5 minutes", -} - - -class RunTestButton(_DiskButtonBase): - """Starts a SMART self-test on the disk via SSH.""" - - def __init__(self, coordinator, entry, device, host, server_type, test_type: str) -> None: - super().__init__(coordinator, entry, device, host, server_type) - self._test_type = test_type - label, icon = _TEST_LABELS[test_type] - self._attr_unique_id = f"{host}_{self._dev_slug}_run_{test_type}_test" - self._attr_name = label - self._attr_icon = icon - - async def async_press(self) -> None: - fetcher = self.coordinator.fetcher - test_type = self._test_type - hass = self.hass - - _LOGGER.info("Starting %s SMART self-test on %s:%s", test_type, self._host, self._device) - - try: - output = await hass.async_add_executor_job( - fetcher.run_test, self._device, test_type - ) - except Exception as exc: - _LOGGER.error("Failed to start self-test: %s", exc) - notify_create( - hass, - message=f"Failed to start **{test_type}** self-test on `{self._device}` ({self._host}):\n```\n{exc}\n```", - title="SMART Self-Test Error", - notification_id=f"smart_test_error_{self._host}_{self._dev_slug}", - ) - return - - notify_create( - hass, - message=( - f"**{test_type.capitalize()} self-test** started on " - f"`{self._device}` ({self._host}).\n\n" - f"Expected duration: **{_TEST_DURATION[test_type]}**.\n\n" - f"
Raw output\n\n```\n{output}\n```\n
" - ), - title="SMART Self-Test Started", - notification_id=f"smart_test_started_{self._host}_{self._dev_slug}", - ) - - if test_type == "short": - import asyncio - - async def _delayed_refresh() -> None: - await asyncio.sleep(130) - await self.coordinator.async_request_refresh() - - hass.async_create_task(_delayed_refresh()) - - -# --------------------------------------------------------------------------- -# Download Report button -# --------------------------------------------------------------------------- - -class DownloadReportButton(_DiskButtonBase): - """Fetches full smartctl -x report, saves to /config/smart_reports/.""" - - _attr_icon = "mdi:download-circle-outline" - - def __init__(self, coordinator, entry, device, host, server_type) -> None: - super().__init__(coordinator, entry, device, host, server_type) - self._attr_unique_id = f"{host}_{self._dev_slug}_download_report" - self._attr_name = "Download SMART Report" - - async def async_press(self) -> None: - fetcher = self.coordinator.fetcher - hass = self.hass - - _LOGGER.info("Downloading full SMART report for %s on %s", self._device, self._host) - - try: - report = await hass.async_add_executor_job( - fetcher.download_report, self._device - ) - except Exception as exc: - _LOGGER.error("Failed to download SMART report: %s", exc) - notify_create( - hass, - message=f"Failed to download SMART report for `{self._device}` ({self._host}):\n```\n{exc}\n```", - title="SMART Report Error", - notification_id=f"smart_report_error_{self._host}_{self._dev_slug}", - ) - return - - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - safe_host = self._host.replace(".", "_").replace(":", "_") - filename = f"{safe_host}_{self._dev_slug}_{timestamp}.txt" - filepath = os.path.join(REPORTS_DIR, filename) - - try: - await hass.async_add_executor_job(_write_report, filepath, report) - saved_msg = f"Saved to `/config/smart_reports/{filename}`" - except Exception as exc: - _LOGGER.warning("Could not save report to disk: %s", exc) - saved_msg = f"Could not save file: {exc}" - - preview = report[:3000] + ("\n...(truncated)" if len(report) > 3000 else "") - - notify_create( - hass, - message=( - f"**Full SMART Report** for `{self._device}` on `{self._host}`\n\n" - f"{saved_msg}\n\n" - f"
Report preview\n\n```\n{preview}\n```\n
" - ), - title=f"SMART Report: {self._device} @ {self._host}", - notification_id=f"smart_report_{self._host}_{self._dev_slug}", - ) - - -def _write_report(filepath: str, content: str) -> None: - """Synchronous helper — runs in executor.""" - os.makedirs(os.path.dirname(filepath), exist_ok=True) - with open(filepath, "w", encoding="utf-8") as fh: - fh.write(content) diff --git a/card/README.md b/card/README.md new file mode 100644 index 0000000..97b17c2 --- /dev/null +++ b/card/README.md @@ -0,0 +1,44 @@ +# SMART Monitor card + +A dashboard card for the SMART Disk Monitor integration. It lists every disk +grouped by host and shows its health, temperature, wear, power-on time and +problems at a glance. Click a disk to see all SMART values and start or abort +self-tests. + +It finds the integration's entities on its own; there is nothing to map. + +## Installation + +1. Copy `smart-monitor-card.js` to `/config/www/smart-monitor-card.js`. +2. Go to **Settings → Dashboards → ⋮ → Resources → Add resource**, enter + `/local/smart-monitor-card.js` and choose **JavaScript module**. +3. Reload the browser, then add the **SMART Monitor** card to a dashboard. + +## Configuration + +```yaml +type: custom:smart-monitor-card +title: Disks # optional +host: 0123456789abcdef # optional: device ID of one host +show_diagnostics: false # optional: always list every SMART value +``` + +All options can also be set in the visual editor. + +## What it shows + +- A status icon and **Passed / Warning / Failed** for every disk +- Temperature, orange from 45 °C and red from 55 °C +- Endurance used as a bar for SSDs +- **Bad sectors**, **Standby** and **Self-test running** labels +- When opened: power-on time, error counters (red when not zero), the last + self-test and self-test buttons + +## Development + +Open `demo.html` through a local web server, for example +`python3 -m http.server --directory card`, to see the card with made-up data +without Home Assistant. + +Core integrations cannot ship dashboard cards, so the card is separate from +the integration. It can be published as its own HACS dashboard repository. diff --git a/card/demo.html b/card/demo.html new file mode 100644 index 0000000..81818c3 --- /dev/null +++ b/card/demo.html @@ -0,0 +1,112 @@ + + + + + + SMART Monitor card demo + + + + +
+ + + diff --git a/card/smart-monitor-card.js b/card/smart-monitor-card.js new file mode 100644 index 0000000..a4ae7f2 --- /dev/null +++ b/card/smart-monitor-card.js @@ -0,0 +1,400 @@ +/* + * SMART Monitor card for Home Assistant. + * + * Shows every disk of the SMART Disk Monitor integration, grouped by host, + * with its health, temperature, wear and error counters. No build step: + * add this file as a dashboard resource (type "module"). + * + * type: custom:smart-monitor-card + * title: Disks # optional + * host: # optional, only show the disks of one host + * show_diagnostics: true # optional, list every SMART value per disk + */ + +const VERSION = "1.0.0"; +const PLATFORM = "smart_monitor"; + +// Keys are the integration's entity translation keys. +const HEALTH = { + passed: { label: "Passed", color: "var(--success-color, #43a047)", icon: "mdi:check-circle" }, + warning: { label: "Warning", color: "var(--warning-color, #ffa000)", icon: "mdi:alert" }, + failed: { label: "Failed", color: "var(--error-color, #db4437)", icon: "mdi:close-octagon" }, +}; + +const DETAIL_KEYS = [ + ["power_on_hours", "Power-on time"], + ["power_cycles", "Power cycles"], + ["data_written", "Data written"], + ["data_read", "Data read"], + ["available_spare", "Available spare"], + ["reallocated_sectors", "Reallocated sectors"], + ["pending_sectors", "Pending sectors"], + ["offline_uncorrectable", "Offline uncorrectable"], + ["grown_defects", "Grown defects"], + ["media_errors", "Media errors"], + ["udma_crc_errors", "Interface CRC errors"], + ["uncorrected_read_errors", "Uncorrected read errors"], + ["uncorrected_write_errors", "Uncorrected write errors"], + ["uncorrected_verify_errors", "Uncorrected verify errors"], + ["reported_uncorrectable", "Reported uncorrectable"], + ["command_timeouts", "Command timeouts"], + ["unsafe_shutdowns", "Unsafe shutdowns"], + ["error_log_entries", "Error log entries"], + ["last_self_test", "Last self-test"], +]; + +const ERROR_KEYS = new Set([ + "reallocated_sectors", + "pending_sectors", + "offline_uncorrectable", + "grown_defects", + "media_errors", + "uncorrected_read_errors", + "uncorrected_write_errors", +]); + +const escape = (value) => + String(value ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]); + +const usable = (state) => state && !["unknown", "unavailable"].includes(state.state); + +function formatDuration(hours) { + const value = Number(hours); + if (!Number.isFinite(value)) return "–"; + if (value < 48) return `${Math.round(value)} h`; + const days = value / 24; + if (days < 365) return `${Math.round(days)} d`; + return `${(days / 365).toFixed(1)} y`; +} + +class SmartMonitorCard extends HTMLElement { + static getConfigElement() { + return document.createElement("smart-monitor-card-editor"); + } + + static getStubConfig() { + return { title: "Disks" }; + } + + setConfig(config) { + if (!config || typeof config !== "object") throw new Error("Invalid configuration"); + this._config = { show_diagnostics: false, ...config }; + this._expanded ??= new Set(); + this._signature = undefined; + if (this._hass) this._render(); + } + + set hass(hass) { + this._hass = hass; + // Only re-render when one of our entities changed. + const signature = this._entities() + .map((id) => `${id}:${hass.states[id]?.state}:${hass.states[id]?.last_updated}`) + .join("|"); + if (signature === this._signature) return; + this._signature = signature; + this._render(); + } + + getCardSize() { + return 1 + this._disks().length * 2; + } + + getGridOptions() { + return { columns: 12, min_columns: 6 }; + } + + _entities() { + const entities = this._hass?.entities ?? {}; + return Object.keys(entities).filter((id) => entities[id].platform === PLATFORM); + } + + _disks() { + const hass = this._hass; + if (!hass) return []; + const disks = new Map(); + for (const entityId of this._entities()) { + const entry = hass.entities[entityId]; + const device = hass.devices?.[entry.device_id]; + if (!device || !device.via_device_id) continue; // the host device itself + if (this._config.host && device.via_device_id !== this._config.host) continue; + const state = hass.states[entityId]; + let key = entry.translation_key; + if (!key && state?.attributes.device_class === "temperature") key = "temperature"; + if (!key) continue; + const domain = entityId.split(".", 1)[0]; + if (!disks.has(device.id)) { + disks.set(device.id, { device, host: hass.devices[device.via_device_id], entities: {} }); + } + disks.get(device.id).entities[`${domain}.${key}`] = entityId; + } + return [...disks.values()].sort( + (a, b) => + (a.host?.name_by_user || a.host?.name || "").localeCompare(b.host?.name_by_user || b.host?.name || "") || + (a.device.name_by_user || a.device.name || "").localeCompare(b.device.name_by_user || b.device.name || ""), + ); + } + + _state(disk, key) { + const entityId = disk.entities[key]; + return entityId ? this._hass.states[entityId] : undefined; + } + + _format(disk, key) { + const state = this._state(disk, key); + if (!usable(state)) return undefined; + if (key === "sensor.power_on_hours") return formatDuration(state.state); + if (key === "sensor.last_self_test") { + return this._hass.formatEntityState?.(state) ?? state.state; + } + return this._hass.formatEntityState?.(state) ?? `${state.state} ${state.attributes.unit_of_measurement ?? ""}`; + } + + _render() { + if (!this._config || !this._hass) return; + if (!this.shadowRoot) this.attachShadow({ mode: "open" }); + + const disks = this._disks(); + let lastHost; + const rows = disks + .map((disk) => { + const hostName = disk.host?.name_by_user || disk.host?.name || ""; + const header = + hostName !== lastHost && !this._config.host + ? `
${escape(hostName)}
` + : ""; + lastHost = hostName; + return header + this._renderDisk(disk); + }) + .join(""); + + const failing = disks.filter((disk) => ["failed", "warning"].includes(this._state(disk, "sensor.health")?.state)).length; + const title = this._config.title ?? ""; + + this.shadowRoot.innerHTML = ` + + + ${ + title || disks.length + ? `
+ ${escape(title)} + + ${failing ? `${failing} of ${disks.length} need attention` : `${disks.length} disks healthy`} + +
` + : "" + } +
+ ${rows || `
No disks from the SMART Disk Monitor integration were found.
`} +
+
`; + + this.shadowRoot.querySelectorAll("[data-more-info]").forEach((element) => + element.addEventListener("click", (event) => { + event.stopPropagation(); + this.dispatchEvent( + new CustomEvent("hass-more-info", { + bubbles: true, + composed: true, + detail: { entityId: element.dataset.moreInfo }, + }), + ); + }), + ); + this.shadowRoot.querySelectorAll("[data-toggle]").forEach((element) => + element.addEventListener("click", () => { + const id = element.dataset.toggle; + if (this._expanded.has(id)) this._expanded.delete(id); + else this._expanded.add(id); + this._signature = undefined; + this._render(); + }), + ); + this.shadowRoot.querySelectorAll("[data-press]").forEach((element) => + element.addEventListener("click", (event) => { + event.stopPropagation(); + this._hass.callService("button", "press", { entity_id: element.dataset.press }); + }), + ); + } + + _renderDisk(disk) { + const { device } = disk; + const health = this._state(disk, "sensor.health"); + const healthInfo = HEALTH[health?.state] ?? { + label: health?.state === "unavailable" ? "Unavailable" : "Unknown", + color: "var(--disabled-text-color, #9e9e9e)", + icon: "mdi:help-circle", + }; + const temperature = this._format(disk, "sensor.temperature"); + const temperatureValue = Number(this._state(disk, "sensor.temperature")?.state); + const used = Number(this._state(disk, "sensor.percentage_used")?.state); + const standby = this._state(disk, "binary_sensor.standby")?.state === "on"; + const testing = this._state(disk, "binary_sensor.self_test_running")?.state === "on"; + const badSectors = this._state(disk, "binary_sensor.sector_problem")?.state === "on"; + const powerOn = this._format(disk, "sensor.power_on_hours"); + const expanded = this._config.show_diagnostics || this._expanded.has(device.id); + + const chips = [ + standby && `Standby`, + testing && `Self-test running`, + badSectors && `Bad sectors`, + ] + .filter(Boolean) + .join(""); + + const tempClass = temperatureValue >= 55 ? "bad" : temperatureValue >= 45 ? "warn" : ""; + const wear = Number.isFinite(used) + ? `
+
= 70 ? "var(--warning-color, #ffa000)" : "var(--primary-color)" + }">
+ ${Math.round(used)}% used +
` + : ""; + + return ` +
+
+ +
+
+
${escape(device.name_by_user || device.name)}
+
+ ${escape(healthInfo.label)} + ${device.manufacturer ? `· ${escape(device.manufacturer)}` : ""} + ${powerOn ? `· ${escape(powerOn)} on` : ""} +
+ ${chips ? `
${chips}
` : ""} + ${wear} +
+
${temperature ? escape(temperature) : ""}
+ +
+ ${expanded ? this._renderDetails(disk) : ""}`; + } + + _renderDetails(disk) { + const rows = DETAIL_KEYS.map(([key, label]) => { + const fullKey = `sensor.${key}`; + const value = this._format(disk, fullKey); + if (value === undefined) return ""; + const raw = Number(this._state(disk, fullKey)?.state); + const bad = ERROR_KEYS.has(key) && raw > 0; + return `
+ ${label}${escape(value)} +
`; + }).join(""); + + const buttons = [ + ["button.self_test_short", "Short test", "mdi:play-circle-outline"], + ["button.self_test_long", "Long test", "mdi:play-circle"], + ["button.abort_self_test", "Abort", "mdi:stop-circle-outline"], + ] + .filter(([key]) => usable(this._state(disk, key)) || this._state(disk, key)?.state === "unknown") + .map( + ([key, label, icon]) => + ``, + ) + .join(""); + + return `
${rows}${buttons ? `
${buttons}
` : ""}
`; + } +} + +const STYLE = ` + ha-card { overflow: hidden; } + .header { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; padding: 16px 16px 4px; } + .title { font-size: var(--ha-card-header-font-size, 1.25rem); font-weight: 500; } + .summary { color: var(--secondary-text-color); font-size: 0.875rem; } + .summary.bad { color: var(--error-color, #db4437); font-weight: 500; } + .disks { padding: 4px 0 8px; } + .host { display: flex; align-items: center; gap: 8px; padding: 12px 16px 4px; color: var(--secondary-text-color); + font-size: 0.75rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.04em; } + .host ha-icon { --mdc-icon-size: 16px; } + .disk { display: grid; grid-template-columns: 40px 1fr auto 24px; align-items: center; gap: 12px; + padding: 8px 16px; cursor: pointer; } + .disk:hover { background: var(--secondary-background-color); } + .status ha-icon { --mdc-icon-size: 32px; } + .main { min-width: 0; } + .name { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .meta { color: var(--secondary-text-color); font-size: 0.8125rem; } + .chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; } + .chip { display: inline-flex; align-items: center; gap: 4px; padding: 1px 8px; border-radius: 12px; font-size: 0.75rem; + background: var(--secondary-background-color); color: var(--secondary-text-color); } + .chip ha-icon { --mdc-icon-size: 14px; } + .chip.bad { background: color-mix(in srgb, var(--error-color, #db4437) 15%, transparent); color: var(--error-color, #db4437); } + .wear { display: flex; align-items: center; gap: 8px; margin-top: 6px; font-size: 0.75rem; color: var(--secondary-text-color); } + .bar { flex: 1; max-width: 160px; height: 6px; border-radius: 3px; background: var(--divider-color); overflow: hidden; } + .bar > div { height: 100%; border-radius: 3px; } + .temperature { font-size: 1.125rem; font-weight: 500; white-space: nowrap; } + .temperature.warn { color: var(--warning-color, #ffa000); } + .temperature.bad { color: var(--error-color, #db4437); } + .chevron { color: var(--secondary-text-color); } + .details { padding: 0 16px 12px 68px; display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 2px 16px; } + .detail { display: flex; justify-content: space-between; gap: 8px; font-size: 0.8125rem; padding: 3px 0; cursor: pointer; + border-bottom: 1px solid var(--divider-color); } + .detail span:first-child { color: var(--secondary-text-color); } + .detail.bad span:last-child { color: var(--error-color, #db4437); font-weight: 500; } + .actions { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 8px; padding-top: 8px; } + button { display: inline-flex; align-items: center; gap: 4px; font: inherit; font-size: 0.8125rem; cursor: pointer; + padding: 4px 12px; border-radius: 16px; border: 1px solid var(--divider-color); + background: none; color: var(--primary-color); } + button:hover { background: var(--secondary-background-color); } + button ha-icon { --mdc-icon-size: 16px; } + .empty { padding: 16px; color: var(--secondary-text-color); } + @media (max-width: 450px) { .details { padding-left: 16px; } } +`; + +class SmartMonitorCardEditor extends HTMLElement { + setConfig(config) { + this._config = config; + this._render(); + } + + set hass(hass) { + this._hass = hass; + this._render(); + } + + _render() { + if (!this._hass || !this._config) return; + if (!this._form) { + this._form = document.createElement("ha-form"); + this._form.computeLabel = (schema) => + ({ title: "Title", host: "Only show disks of this host", show_diagnostics: "Always show all SMART values" })[ + schema.name + ]; + this._form.addEventListener("value-changed", (event) => { + const config = { ...event.detail.value }; + for (const key of Object.keys(config)) if (config[key] === "" || config[key] === undefined) delete config[key]; + this.dispatchEvent(new CustomEvent("config-changed", { detail: { config }, bubbles: true, composed: true })); + }); + this.appendChild(this._form); + } + this._form.hass = this._hass; + this._form.data = this._config; + this._form.schema = [ + { name: "title", selector: { text: {} } }, + { name: "host", selector: { device: { integration: PLATFORM } } }, + { name: "show_diagnostics", selector: { boolean: {} } }, + ]; + } +} + +customElements.define("smart-monitor-card", SmartMonitorCard); +customElements.define("smart-monitor-card-editor", SmartMonitorCardEditor); + +window.customCards = window.customCards || []; +window.customCards.push({ + type: "smart-monitor-card", + name: "SMART Monitor", + description: "Health, temperature and wear of the disks of the SMART Disk Monitor integration.", + preview: true, + documentationURL: "https://github.com/ChristianHandy/HAOS_SMART_Monitor/tree/main/card", +}); + +console.info(`%c SMART-MONITOR-CARD %c ${VERSION} `, "color:white;background:#03a9f4;font-weight:700", "color:#03a9f4"); diff --git a/config_flow.py b/config_flow.py deleted file mode 100644 index af71124..0000000 --- a/config_flow.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Config flow for SMART Disk Monitor.""" -from __future__ import annotations - -import logging -from typing import Any - -import voluptuous as vol -from homeassistant import config_entries -from homeassistant.core import HomeAssistant, callback -from homeassistant.data_entry_flow import FlowResult - -from .const import ( - DOMAIN, - CONF_HOST, - CONF_PORT, - CONF_USERNAME, - CONF_PASSWORD, - CONF_SSH_KEY, - CONF_SERVER_TYPE, - CONF_SCAN_INTERVAL, - SERVER_TYPES, - DEFAULT_PORT, - DEFAULT_SCAN_INTERVAL, -) -from .smart_fetcher import SmartDataFetcher - -_LOGGER = logging.getLogger(__name__) - -STEP_USER_SCHEMA = vol.Schema( - { - vol.Required(CONF_HOST): str, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, - vol.Required(CONF_USERNAME): str, - vol.Optional(CONF_PASSWORD): str, - vol.Optional(CONF_SSH_KEY): str, - vol.Optional(CONF_SERVER_TYPE, default="generic_linux"): vol.In(SERVER_TYPES), - vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): int, - } -) - - -async def _test_connection(hass: HomeAssistant, data: dict[str, Any]) -> None: - """Try to connect and list disks; raise on failure.""" - fetcher = SmartDataFetcher( - host=data[CONF_HOST], - port=data[CONF_PORT], - username=data[CONF_USERNAME], - password=data.get(CONF_PASSWORD), - ssh_key_path=data.get(CONF_SSH_KEY), - server_type=data.get(CONF_SERVER_TYPE, "generic_linux"), - ) - await hass.async_add_executor_job(fetcher.connect) - await hass.async_add_executor_job(fetcher.disconnect) - - -class SmartMonitorConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for SMART Disk Monitor.""" - - VERSION = 1 - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - errors: dict[str, str] = {} - - if user_input is not None: - try: - await _test_connection(self.hass, user_input) - except Exception as exc: # noqa: BLE001 - _LOGGER.error("Connection test failed: %s", exc) - errors["base"] = "cannot_connect" - else: - title = f"{user_input[CONF_SERVER_TYPE].capitalize()} – {user_input[CONF_HOST]}" - return self.async_create_entry(title=title, data=user_input) - - return self.async_show_form( - step_id="user", - data_schema=STEP_USER_SCHEMA, - errors=errors, - description_placeholders={ - "server_types": ", ".join(SERVER_TYPES), - }, - ) - - @staticmethod - @callback - def async_get_options_flow(config_entry: config_entries.ConfigEntry) -> SmartMonitorOptionsFlow: - return SmartMonitorOptionsFlow(config_entry) - - -class SmartMonitorOptionsFlow(config_entries.OptionsFlow): - """Allow updating scan interval and credentials without re-adding.""" - - def __init__(self, config_entry: config_entries.ConfigEntry) -> None: - self.config_entry = config_entry - - async def async_step_init( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - if user_input is not None: - return self.async_create_entry(title="", data=user_input) - - current = self.config_entry.data - schema = vol.Schema( - { - vol.Optional( - CONF_SCAN_INTERVAL, - default=current.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL), - ): int, - vol.Optional(CONF_PASSWORD, default=current.get(CONF_PASSWORD, "")): str, - vol.Optional(CONF_SSH_KEY, default=current.get(CONF_SSH_KEY, "")): str, - } - ) - return self.async_show_form(step_id="init", data_schema=schema) diff --git a/const.py b/const.py deleted file mode 100644 index 51fa595..0000000 --- a/const.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Constants for the SMART Disk Monitor integration.""" - -DOMAIN = "smart_monitor" -CONF_HOST = "host" -CONF_PORT = "port" -CONF_USERNAME = "username" -CONF_PASSWORD = "password" -CONF_SSH_KEY = "ssh_key" -CONF_SERVER_TYPE = "server_type" -CONF_SCAN_INTERVAL = "scan_interval" - -SERVER_TYPE_UNRAID = "unraid" -SERVER_TYPE_PROXMOX = "proxmox" -SERVER_TYPE_GENERIC_LINUX = "generic_linux" - -SERVER_TYPES = [SERVER_TYPE_UNRAID, SERVER_TYPE_PROXMOX, SERVER_TYPE_GENERIC_LINUX] - -DEFAULT_PORT = 22 -DEFAULT_SCAN_INTERVAL = 300 # seconds (5 minutes) - -# SMART attribute IDs we care about -SMART_ATTRS = { - 1: {"name": "Raw Read Error Rate", "icon": "mdi:alert-circle"}, - 3: {"name": "Spin Up Time", "icon": "mdi:timer"}, - 4: {"name": "Start/Stop Count", "icon": "mdi:counter"}, - 5: {"name": "Reallocated Sectors Count", "icon": "mdi:alert"}, - 7: {"name": "Seek Error Rate", "icon": "mdi:alert-circle-outline"}, - 9: {"name": "Power On Hours", "icon": "mdi:clock-outline"}, - 10: {"name": "Spin Retry Count", "icon": "mdi:rotate-right"}, - 12: {"name": "Power Cycle Count", "icon": "mdi:power"}, - 177: {"name": "Wear Leveling Count", "icon": "mdi:chart-line"}, - 187: {"name": "Reported Uncorrectable", "icon": "mdi:close-circle"}, - 188: {"name": "Command Timeout", "icon": "mdi:timer-off"}, - 190: {"name": "Airflow Temperature", "icon": "mdi:thermometer"}, - 194: {"name": "Temperature", "icon": "mdi:thermometer"}, - 196: {"name": "Reallocation Event Count", "icon": "mdi:alert"}, - 197: {"name": "Current Pending Sector", "icon": "mdi:alert-outline"}, - 198: {"name": "Offline Uncorrectable", "icon": "mdi:close-circle-outline"}, - 199: {"name": "UDMA CRC Error Count", "icon": "mdi:lan-disconnect"}, - 231: {"name": "SSD Life Left", "icon": "mdi:battery"}, - 233: {"name": "Media Wearout Indicator", "icon": "mdi:harddisk"}, -} - -# NVMe specific attributes -NVME_ATTRS = { - "temperature": {"name": "Temperature", "icon": "mdi:thermometer", "unit": "°C"}, - "available_spare": {"name": "Available Spare", "icon": "mdi:battery", "unit": "%"}, - "available_spare_threshold": {"name": "Available Spare Threshold","icon": "mdi:battery-alert", "unit": "%"}, - "percentage_used": {"name": "Percentage Used", "icon": "mdi:chart-pie", "unit": "%"}, - "data_units_read": {"name": "Data Units Read", "icon": "mdi:download", "unit": ""}, - "data_units_written": {"name": "Data Units Written", "icon": "mdi:upload", "unit": ""}, - "power_on_hours": {"name": "Power On Hours", "icon": "mdi:clock-outline", "unit": "h"}, - "power_cycles": {"name": "Power Cycles", "icon": "mdi:power", "unit": ""}, - "unsafe_shutdowns": {"name": "Unsafe Shutdowns", "icon": "mdi:power-off", "unit": ""}, - "media_errors": {"name": "Media Errors", "icon": "mdi:alert", "unit": ""}, - "num_err_log_entries": {"name": "Error Log Entries", "icon": "mdi:format-list-text","unit": ""}, -} - -# Health status levels -HEALTH_PASSED = "PASSED" -HEALTH_FAILED = "FAILED" -HEALTH_UNKNOWN = "UNKNOWN" diff --git a/coordinator.py b/coordinator.py deleted file mode 100644 index 1a7a0df..0000000 --- a/coordinator.py +++ /dev/null @@ -1,49 +0,0 @@ -"""DataUpdateCoordinator for SMART Disk Monitor.""" -from __future__ import annotations - -import logging -from datetime import timedelta -from typing import Any - -from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed - -from .const import DOMAIN, DEFAULT_SCAN_INTERVAL -from .smart_fetcher import SmartDataFetcher, DiskSmartData - -_LOGGER = logging.getLogger(__name__) - - -class SmartMonitorCoordinator(DataUpdateCoordinator[dict[str, dict[str, DiskSmartData]]]): - """Manages polling SMART data for one or more servers.""" - - def __init__( - self, - hass: HomeAssistant, - entry_id: str, - fetcher: SmartDataFetcher, - server_name: str, - scan_interval: int = DEFAULT_SCAN_INTERVAL, - ) -> None: - self.fetcher = fetcher - self.server_name = server_name - self.entry_id = entry_id - - super().__init__( - hass, - _LOGGER, - name=f"{DOMAIN}_{server_name}", - update_interval=timedelta(seconds=scan_interval), - ) - - async def _async_update_data(self) -> dict[str, DiskSmartData]: - """Fetch data from the remote server.""" - try: - data = await self.hass.async_add_executor_job(self.fetcher.fetch_all_disks) - except Exception as exc: - raise UpdateFailed(f"Error fetching SMART data from {self.server_name}: {exc}") from exc - - if not data: - _LOGGER.warning("No disks found on server %s", self.server_name) - - return data diff --git a/custom_components/smart_monitor/__init__.py b/custom_components/smart_monitor/__init__.py new file mode 100644 index 0000000..374460e --- /dev/null +++ b/custom_components/smart_monitor/__init__.py @@ -0,0 +1,84 @@ +"""The SMART Disk Monitor integration.""" + +from collections.abc import Mapping +from typing import Any + +from aiosmartctl import SmartctlClient + +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr + +from .const import CONF_HOST_KEY, CONF_PRIVATE_KEY, CONF_USE_SUDO, DOMAIN +from .coordinator import SmartMonitorConfigEntry, SmartMonitorCoordinator + +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR] + + +def create_client(data: Mapping[str, Any]) -> SmartctlClient: + """Create a smartctl client from config entry data.""" + return SmartctlClient( + data[CONF_HOST], + port=data[CONF_PORT], + username=data[CONF_USERNAME], + password=data.get(CONF_PASSWORD) or None, + private_key=data.get(CONF_PRIVATE_KEY) or None, + known_host_key=data.get(CONF_HOST_KEY) or None, + use_sudo=data.get(CONF_USE_SUDO, False), + ) + + +async def async_setup_entry( + hass: HomeAssistant, entry: SmartMonitorConfigEntry +) -> bool: + """Set up SMART Disk Monitor from a config entry.""" + if not entry.data.get(CONF_HOST_KEY): + # Entries made before host keys were pinned need the user to confirm one. + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="host_key_missing" + ) + + coordinator = SmartMonitorCoordinator(hass, entry, create_client(entry.data)) + await coordinator.async_config_entry_first_refresh() + + host = coordinator.host_info + coordinator.host_device_id = ( + dr.async_get(hass) + .async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, str(entry.unique_id))}, + name=host.hostname or entry.data[CONF_HOST], + model=host.os_name, + sw_version=host.os_version, + ) + .id + ) + + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: SmartMonitorConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_remove_config_entry_device( + hass: HomeAssistant, entry: SmartMonitorConfigEntry, device: dr.AnyDeviceEntry +) -> bool: + """Allow removing disks that are no longer attached to the host.""" + return not any( + domain == DOMAIN + and (identifier == entry.unique_id or identifier in entry.runtime_data.data) + for domain, identifier in device.identifiers + ) diff --git a/custom_components/smart_monitor/binary_sensor.py b/custom_components/smart_monitor/binary_sensor.py new file mode 100644 index 0000000..ad17780 --- /dev/null +++ b/custom_components/smart_monitor/binary_sensor.py @@ -0,0 +1,125 @@ +"""Binary sensors for SMART Disk Monitor.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import override + +from aiosmartctl import Disk, HealthStatus + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import SmartMonitorConfigEntry, SmartMonitorCoordinator +from .entity import ( + SmartMonitorEntity, + SmartMonitorEntityDescription, + async_setup_disk_entities, +) + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class SmartMonitorBinarySensorEntityDescription( + BinarySensorEntityDescription, SmartMonitorEntityDescription +): + """Describes a disk binary sensor.""" + + is_on_fn: Callable[[Disk], bool | None] + exists_fn: Callable[[Disk], bool] = lambda _: True + + +def _health_problem(disk: Disk) -> bool | None: + if disk.health is HealthStatus.UNKNOWN: + return None + return disk.health is not HealthStatus.PASSED + + +_BAD_SECTOR_COUNTS: tuple[Callable[[Disk], int | None], ...] = ( + lambda disk: disk.reallocated_sectors, + lambda disk: disk.pending_sectors, + lambda disk: disk.offline_uncorrectable, + lambda disk: disk.grown_defects, + lambda disk: disk.media_errors, +) + + +def _sector_problem(disk: Disk) -> bool | None: + counts = [ + count for getter in _BAD_SECTOR_COUNTS if (count := getter(disk)) is not None + ] + if not counts: + return None + return any(counts) + + +BINARY_SENSORS: tuple[SmartMonitorBinarySensorEntityDescription, ...] = ( + SmartMonitorBinarySensorEntityDescription( + key="health_problem", + translation_key="health_problem", + device_class=BinarySensorDeviceClass.PROBLEM, + is_on_fn=_health_problem, + ), + SmartMonitorBinarySensorEntityDescription( + key="sector_problem", + translation_key="sector_problem", + device_class=BinarySensorDeviceClass.PROBLEM, + is_on_fn=_sector_problem, + exists_fn=lambda disk: _sector_problem(disk) is not None, + ), + SmartMonitorBinarySensorEntityDescription( + key="self_test_running", + translation_key="self_test_running", + device_class=BinarySensorDeviceClass.RUNNING, + entity_category=EntityCategory.DIAGNOSTIC, + is_on_fn=lambda disk: disk.self_test_in_progress, + exists_fn=lambda disk: bool(disk.supported_self_tests), + ), + SmartMonitorBinarySensorEntityDescription( + key="standby", + translation_key="standby", + entity_category=EntityCategory.DIAGNOSTIC, + is_on_fn=lambda disk: disk.standby, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartMonitorConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up binary sensors for all disks.""" + async_setup_disk_entities( + entry.runtime_data, + async_add_entities, + BINARY_SENSORS, + SmartMonitorBinarySensorEntity, + ) + + +class SmartMonitorBinarySensorEntity(SmartMonitorEntity, BinarySensorEntity): + """A binary state of a disk.""" + + entity_description: SmartMonitorBinarySensorEntityDescription + + def __init__( + self, + coordinator: SmartMonitorCoordinator, + disk_id: str, + description: SmartMonitorBinarySensorEntityDescription, + ) -> None: + """Initialize the binary sensor.""" + super().__init__(coordinator, disk_id, description) + + @property + @override + def is_on(self) -> bool | None: + """Return the state.""" + return self.entity_description.is_on_fn(self.disk) diff --git a/custom_components/smart_monitor/button.py b/custom_components/smart_monitor/button.py new file mode 100644 index 0000000..39ffc28 --- /dev/null +++ b/custom_components/smart_monitor/button.py @@ -0,0 +1,105 @@ +"""Buttons that start and abort SMART self-tests.""" + +from dataclasses import dataclass +from typing import override + +from aiosmartctl import SelfTestType, SmartctlError, SmartctlSession + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import SmartMonitorConfigEntry, SmartMonitorCoordinator +from .entity import ( + SmartMonitorEntity, + SmartMonitorEntityDescription, + async_setup_disk_entities, +) + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class SmartMonitorButtonEntityDescription( + ButtonEntityDescription, SmartMonitorEntityDescription +): + """Describes a self-test button.""" + + test_type: SelfTestType | None + + +def _self_test_button(test_type: SelfTestType) -> SmartMonitorButtonEntityDescription: + return SmartMonitorButtonEntityDescription( + key=f"self_test_{test_type}", + translation_key=f"self_test_{test_type}", + entity_category=EntityCategory.DIAGNOSTIC, + test_type=test_type, + exists_fn=lambda disk: test_type in disk.supported_self_tests, + ) + + +BUTTONS: tuple[SmartMonitorButtonEntityDescription, ...] = ( + _self_test_button(SelfTestType.SHORT), + _self_test_button(SelfTestType.LONG), + _self_test_button(SelfTestType.CONVEYANCE), + SmartMonitorButtonEntityDescription( + key="abort_self_test", + translation_key="abort_self_test", + entity_category=EntityCategory.DIAGNOSTIC, + test_type=None, + exists_fn=lambda disk: bool(disk.supported_self_tests), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartMonitorConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up self-test buttons for all disks.""" + async_setup_disk_entities( + entry.runtime_data, async_add_entities, BUTTONS, SmartMonitorButtonEntity + ) + + +class SmartMonitorButtonEntity(SmartMonitorEntity, ButtonEntity): + """Starts or aborts a self-test on a disk.""" + + entity_description: SmartMonitorButtonEntityDescription + + def __init__( + self, + coordinator: SmartMonitorCoordinator, + disk_id: str, + description: SmartMonitorButtonEntityDescription, + ) -> None: + """Initialize the button.""" + super().__init__(coordinator, disk_id, description) + + @override + async def async_press(self) -> None: + """Start or abort the self-test.""" + test_type = self.entity_description.test_type + spec = self.disk.spec + try: + async with self.coordinator.client.session() as session: + await self._run(session, test_type) + except SmartctlError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="self_test_failed", + translation_placeholders={"device": spec.key, "error": str(err)}, + ) from err + await self.coordinator.async_request_refresh() + + async def _run( + self, session: SmartctlSession, test_type: SelfTestType | None + ) -> None: + if test_type is None: + await session.abort_self_test(self.disk.spec) + else: + await session.start_self_test(self.disk.spec, test_type) diff --git a/custom_components/smart_monitor/config_flow.py b/custom_components/smart_monitor/config_flow.py new file mode 100644 index 0000000..3774a0b --- /dev/null +++ b/custom_components/smart_monitor/config_flow.py @@ -0,0 +1,375 @@ +"""Config flow for SMART Disk Monitor.""" + +from collections.abc import Mapping +from typing import Any, override + +from aiosmartctl import ( + DeviceSpec, + HostInfo, + SmartctlAuthenticationError, + SmartctlConnectionError, + SmartctlError, + SmartctlHostKeyError, + SmartctlNotFoundError, + SmartctlPermissionError, + fetch_host_key, + host_key_fingerprint, + validate_private_key, +) +import probatio + +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) +from homeassistant.const import ( + CONF_DEVICES, + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, +) +from homeassistant.core import callback +from homeassistant.helpers.selector import ( + BooleanSelector, + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from . import create_client +from .const import ( + CONF_HOST_KEY, + CONF_PRIVATE_KEY, + CONF_SKIP_STANDBY, + CONF_USE_SUDO, + DEFAULT_PORT, + DEFAULT_SKIP_STANDBY, + DOMAIN, + LOGGER, +) +from .coordinator import SmartMonitorConfigEntry + +_PASSWORD = TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD)) +_PRIVATE_KEY = TextSelector(TextSelectorConfig(multiline=True)) +_PORT = NumberSelector( + NumberSelectorConfig(min=1, max=65535, step=1, mode=NumberSelectorMode.BOX) +) + +USER_SCHEMA = probatio.Schema( + { + probatio.Required(CONF_HOST): TextSelector(), + probatio.Required(CONF_PORT, default=DEFAULT_PORT): _PORT, + probatio.Required(CONF_USERNAME, default="root"): TextSelector(), + probatio.Optional(CONF_PASSWORD): _PASSWORD, + probatio.Optional(CONF_PRIVATE_KEY): _PRIVATE_KEY, + probatio.Required(CONF_USE_SUDO, default=False): BooleanSelector(), + } +) +CREDENTIALS_SCHEMA = probatio.Schema( + { + probatio.Optional(CONF_PASSWORD): _PASSWORD, + probatio.Optional(CONF_PRIVATE_KEY): _PRIVATE_KEY, + } +) +RECONFIGURE_SCHEMA = probatio.Schema( + { + probatio.Required(CONF_HOST): TextSelector(), + probatio.Required(CONF_PORT): _PORT, + probatio.Required(CONF_USERNAME): TextSelector(), + probatio.Required(CONF_USE_SUDO): BooleanSelector(), + } +) +OPTIONS_SCHEMA = probatio.Schema( + { + probatio.Required(CONF_SKIP_STANDBY): BooleanSelector(), + probatio.Optional(CONF_DEVICES): TextSelector( + TextSelectorConfig(multiline=True) + ), + } +) + + +def _normalize(user_input: dict[str, Any]) -> dict[str, Any]: + """Drop empty secrets and make the port an integer.""" + data = {key: value for key, value in user_input.items() if value != ""} + if CONF_PORT in data: + data[CONF_PORT] = int(data[CONF_PORT]) + if CONF_PRIVATE_KEY in data: + data[CONF_PRIVATE_KEY] = data[CONF_PRIVATE_KEY].strip() + "\n" + return data + + +def _validate_credentials(data: Mapping[str, Any]) -> str | None: + """Return an error key when the credentials cannot work.""" + if not data.get(CONF_PASSWORD) and not data.get(CONF_PRIVATE_KEY): + return "missing_credentials" + if private_key := data.get(CONF_PRIVATE_KEY): + try: + validate_private_key(private_key) + except ValueError: + return "invalid_private_key" + return None + + +async def _async_validate(data: Mapping[str, Any]) -> HostInfo: + """Log in with a pinned host key and make sure smartctl runs.""" + async with create_client(data).session() as session: + return await session.get_host_info() + + +def _error_key(err: SmartctlError) -> str: + if isinstance(err, SmartctlHostKeyError): + return "host_key_changed" + if isinstance(err, SmartctlAuthenticationError): + return "invalid_auth" + if isinstance(err, SmartctlNotFoundError): + return "smartctl_not_found" + if isinstance(err, SmartctlPermissionError): + return "sudo_failed" + if isinstance(err, SmartctlConnectionError): + return "cannot_connect" + return "unknown" + + +class SmartMonitorOptionsFlow(OptionsFlowWithReload): + """Options for SMART Disk Monitor.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the options.""" + errors: dict[str, str] = {} + if user_input is not None: + devices = [ + line.strip() + for line in user_input.get(CONF_DEVICES, "").splitlines() + if line.strip() + ] + try: + for device in devices: + DeviceSpec.parse(device) + except ValueError: + errors[CONF_DEVICES] = "invalid_device" + else: + return self.async_create_entry( + data={ + CONF_SKIP_STANDBY: user_input[CONF_SKIP_STANDBY], + CONF_DEVICES: devices, + } + ) + + options = self.config_entry.options + suggested = user_input or { + CONF_SKIP_STANDBY: options.get(CONF_SKIP_STANDBY, DEFAULT_SKIP_STANDBY), + CONF_DEVICES: "\n".join(options.get(CONF_DEVICES, [])), + } + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema(OPTIONS_SCHEMA, suggested), + errors=errors, + ) + + +class SmartMonitorConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for SMART Disk Monitor.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the flow.""" + self._data: dict[str, Any] = {} + + @staticmethod + @callback + @override + def async_get_options_flow( + config_entry: SmartMonitorConfigEntry, + ) -> SmartMonitorOptionsFlow: + """Return the options flow.""" + return SmartMonitorOptionsFlow() + + async def _async_fetch_host_key(self, data: Mapping[str, Any]) -> str | None: + """Fetch the host key and remember it, or return None when unreachable.""" + try: + host_key = await fetch_host_key(data[CONF_HOST], data[CONF_PORT]) + except SmartctlConnectionError as err: + LOGGER.debug("Cannot fetch host key: %s", err) + return None + self._data[CONF_HOST_KEY] = host_key + return host_key + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Ask for the host and credentials.""" + errors: dict[str, str] = {} + if user_input is not None: + data = _normalize(user_input) + if error := _validate_credentials(data): + errors["base"] = error + else: + self._data = data + if await self._async_fetch_host_key(data) is None: + errors["base"] = "cannot_connect" + else: + return await self.async_step_confirm_host_key() + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + USER_SCHEMA, user_input or {} + ), + errors=errors, + ) + + async def async_step_confirm_host_key( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Let the user check the host key before any credentials are sent.""" + host_key = self._data[CONF_HOST_KEY] + fingerprint = host_key_fingerprint(host_key) + if user_input is None: + return self.async_show_form( + step_id="confirm_host_key", + description_placeholders={ + "host": self._data[CONF_HOST], + "fingerprint": fingerprint, + }, + ) + + await self.async_set_unique_id(fingerprint) + self._abort_if_unique_id_configured() + try: + host_info = await _async_validate(self._data) + except SmartctlError as err: + LOGGER.debug("Validation failed: %s", err) + error = _error_key(err) + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected error") + error = "unknown" + else: + return self.async_create_entry( + title=host_info.hostname or self._data[CONF_HOST], data=self._data + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + USER_SCHEMA, + { + key: value + for key, value in self._data.items() + if key not in (CONF_PASSWORD, CONF_PRIVATE_KEY, CONF_HOST_KEY) + }, + ), + errors={"base": error}, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle rejected credentials or a changed host key.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Ask for new credentials and confirm the current host key.""" + entry = self._get_reauth_entry() + errors: dict[str, str] = {} + + # Pin the key the user is shown, not one fetched again on submit. + host_key = self._data.get(CONF_HOST_KEY) or await self._async_fetch_host_key( + entry.data + ) + if host_key is None: + return self.async_abort(reason="cannot_connect") + fingerprint = host_key_fingerprint(host_key) + + if user_input is not None: + data = { + CONF_HOST: entry.data[CONF_HOST], + CONF_PORT: entry.data[CONF_PORT], + CONF_USERNAME: entry.data[CONF_USERNAME], + CONF_USE_SUDO: entry.data.get(CONF_USE_SUDO, False), + **_normalize(user_input), + CONF_HOST_KEY: host_key, + } + if error := _validate_credentials(data): + errors["base"] = error + else: + try: + await _async_validate(data) + except SmartctlError as err: + errors["base"] = _error_key(err) + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected error") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + entry, unique_id=fingerprint, data=data + ) + + changed = entry.data.get(CONF_HOST_KEY) not in (None, host_key) + return self.async_show_form( + step_id="reauth_host_key_changed" if changed else "reauth_confirm", + data_schema=CREDENTIALS_SCHEMA, + errors=errors, + description_placeholders={ + "host": entry.data[CONF_HOST], + "fingerprint": fingerprint, + }, + ) + + async def async_step_reauth_host_key_changed( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the reauth form shown after the host key changed.""" + return await self.async_step_reauth_confirm(user_input) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Change the address or user of a host.""" + entry = self._get_reconfigure_entry() + errors: dict[str, str] = {} + + if user_input is not None: + data = {**entry.data, **_normalize(user_input)} + host_key = await self._async_fetch_host_key(data) + if host_key is None: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(host_key_fingerprint(host_key)) + self._abort_if_unique_id_mismatch(reason="different_host") + data[CONF_HOST_KEY] = host_key + try: + await _async_validate(data) + except SmartctlError as err: + errors["base"] = _error_key(err) + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected error") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort(entry, data=data) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + RECONFIGURE_SCHEMA, + user_input + or { + CONF_HOST: entry.data[CONF_HOST], + CONF_PORT: entry.data[CONF_PORT], + CONF_USERNAME: entry.data[CONF_USERNAME], + CONF_USE_SUDO: entry.data.get(CONF_USE_SUDO, False), + }, + ), + errors=errors, + ) diff --git a/custom_components/smart_monitor/const.py b/custom_components/smart_monitor/const.py new file mode 100644 index 0000000..ce1fc5d --- /dev/null +++ b/custom_components/smart_monitor/const.py @@ -0,0 +1,17 @@ +"""Constants for the SMART Disk Monitor integration.""" + +from datetime import timedelta +import logging + +DOMAIN = "smart_monitor" +LOGGER = logging.getLogger(__package__) + +SCAN_INTERVAL = timedelta(minutes=5) + +CONF_HOST_KEY = "host_key" +CONF_PRIVATE_KEY = "private_key" +CONF_USE_SUDO = "use_sudo" +CONF_SKIP_STANDBY = "skip_standby" + +DEFAULT_PORT = 22 +DEFAULT_SKIP_STANDBY = True diff --git a/custom_components/smart_monitor/coordinator.py b/custom_components/smart_monitor/coordinator.py new file mode 100644 index 0000000..6524a23 --- /dev/null +++ b/custom_components/smart_monitor/coordinator.py @@ -0,0 +1,134 @@ +"""Data update coordinator for SMART Disk Monitor.""" + +from dataclasses import replace +from typing import override + +from aiosmartctl import ( + DeviceSpec, + Disk, + HostInfo, + SmartctlAuthenticationError, + SmartctlClient, + SmartctlError, + SmartctlHostKeyError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICES +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + CONF_SKIP_STANDBY, + DEFAULT_SKIP_STANDBY, + DOMAIN, + LOGGER, + SCAN_INTERVAL, +) + +type SmartMonitorConfigEntry = ConfigEntry[SmartMonitorCoordinator] + + +class SmartMonitorCoordinator(DataUpdateCoordinator[dict[str, Disk]]): + """Reads SMART data of all disks of one host. + + The data maps a stable disk ID, based on the WWN or serial number, to the + latest reading of that disk. + """ + + config_entry: SmartMonitorConfigEntry + host_info: HostInfo + host_device_id: str + + def __init__( + self, + hass: HomeAssistant, + config_entry: SmartMonitorConfigEntry, + client: SmartctlClient, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self.client = client + self._disk_ids: dict[str, str] = {} + + @override + async def _async_setup(self) -> None: + """Read information about the host once.""" + try: + async with self.client.session() as session: + self.host_info = await session.get_host_info() + except (SmartctlAuthenticationError, SmartctlHostKeyError) as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key=_auth_error_key(err), + ) from err + except SmartctlError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + translation_placeholders={"error": str(err)}, + ) from err + + @override + async def _async_update_data(self) -> dict[str, Disk]: + """Read all disks.""" + options = self.config_entry.options + specs = [DeviceSpec.parse(value) for value in options.get(CONF_DEVICES, [])] + try: + async with self.client.session() as session: + disks = await session.get_disks( + specs or None, + skip_standby=options.get(CONF_SKIP_STANDBY, DEFAULT_SKIP_STANDBY), + ) + except (SmartctlAuthenticationError, SmartctlHostKeyError) as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key=_auth_error_key(err), + ) from err + except SmartctlError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + translation_placeholders={"error": str(err)}, + ) from err + + previous = self.data or {} + result: dict[str, Disk] = {} + for disk in disks: + disk_id = disk.identifier or self._disk_ids.get(disk.spec.key) + if disk_id is None: + if disk.standby or disk.error: + LOGGER.debug( + "Skipping %s until it can be read: %s", + disk.spec.key, + disk.error or "in standby", + ) + continue + # Some USB bridges hide the serial number; fall back to the path. + disk_id = f"{self.config_entry.unique_id}_{disk.spec.key}" + self._disk_ids[disk.spec.key] = disk_id + if old := previous.get(disk_id): + # Keep the last reading of a sleeping or unreadable disk, so + # values do not flap while the disk sleeps. + if disk.standby: + disk = replace(old, spec=disk.spec, standby=True, error=None) + elif disk.error: + disk = replace(old, spec=disk.spec, error=disk.error) + elif disk.error: + LOGGER.warning("Cannot read %s: %s", disk.spec.key, disk.error) + continue + result[disk_id] = disk + return result + + +def _auth_error_key(err: SmartctlError) -> str: + if isinstance(err, SmartctlHostKeyError): + return "host_key_changed" + return "invalid_auth" diff --git a/custom_components/smart_monitor/diagnostics.py b/custom_components/smart_monitor/diagnostics.py new file mode 100644 index 0000000..88d41de --- /dev/null +++ b/custom_components/smart_monitor/diagnostics.py @@ -0,0 +1,48 @@ +"""Diagnostics for SMART Disk Monitor.""" + +from dataclasses import asdict +from typing import Any + +from aiosmartctl import Disk + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .const import CONF_HOST_KEY, CONF_PRIVATE_KEY +from .coordinator import SmartMonitorConfigEntry + +TO_REDACT = { + CONF_HOST, + CONF_HOST_KEY, + CONF_PASSWORD, + CONF_PRIVATE_KEY, + CONF_USERNAME, + "hostname", + "logical_unit_id", + "serial", + "serial_number", + "wwn", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: SmartMonitorConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + return async_redact_data( + { + "entry": {"data": dict(entry.data), "options": dict(entry.options)}, + "host": asdict(coordinator.host_info), + # Disk IDs contain serial numbers, so list the disks without them. + "disks": [_disk(disk) for disk in coordinator.data.values()], + }, + TO_REDACT, + ) + + +def _disk(disk: Disk) -> dict[str, Any]: + data = asdict(disk) + data["supported_self_tests"] = sorted(disk.supported_self_tests) + return data diff --git a/custom_components/smart_monitor/entity.py b/custom_components/smart_monitor/entity.py new file mode 100644 index 0000000..4451784 --- /dev/null +++ b/custom_components/smart_monitor/entity.py @@ -0,0 +1,99 @@ +"""Base entity for SMART Disk Monitor.""" + +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from typing import override + +from aiosmartctl import Disk + +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import SmartMonitorCoordinator + + +@dataclass(frozen=True, kw_only=True) +class SmartMonitorEntityDescription(EntityDescription): + """Describes a disk entity.""" + + exists_fn: Callable[[Disk], bool] + + +class SmartMonitorEntity(CoordinatorEntity[SmartMonitorCoordinator]): + """An entity that belongs to one disk.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: SmartMonitorCoordinator, + disk_id: str, + description: SmartMonitorEntityDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.entity_description = description + self.disk_id = disk_id + self._attr_unique_id = f"{disk_id}_{description.key}" + disk = coordinator.data[disk_id] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, disk_id)}, + name=disk.model or disk.spec.path, + manufacturer=disk.vendor, + model=disk.model, + serial_number=disk.serial, + sw_version=disk.firmware, + via_device_id=coordinator.host_device_id, + ) + + @property + def disk(self) -> Disk: + """Return the latest reading of the disk.""" + return self.coordinator.data[self.disk_id] + + @property + @override + def available(self) -> bool: + """Return if the disk could be read.""" + return ( + super().available + and self.disk_id in self.coordinator.data + and self.disk.error is None + ) + + +def async_setup_disk_entities[_DescriptionT: SmartMonitorEntityDescription]( + coordinator: SmartMonitorCoordinator, + async_add_entities: AddConfigEntryEntitiesCallback, + descriptions: Sequence[_DescriptionT], + entity_factory: Callable[[SmartMonitorCoordinator, str, _DescriptionT], Entity], +) -> None: + """Add entities for every disk, including disks that show up later. + + An entity is added once the disk reports the value it shows, so a disk + that was asleep at startup gets its entities when it wakes up. + """ + added: set[tuple[str, str]] = set() + + @callback + def _add_new_entities() -> None: + entities = [] + for disk_id, disk in coordinator.data.items(): + for description in descriptions: + if (disk_id, description.key) in added or not description.exists_fn( + disk + ): + continue + added.add((disk_id, description.key)) + entities.append(entity_factory(coordinator, disk_id, description)) + if entities: + async_add_entities(entities) + + _add_new_entities() + coordinator.config_entry.async_on_unload( + coordinator.async_add_listener(_add_new_entities) + ) diff --git a/custom_components/smart_monitor/icons.json b/custom_components/smart_monitor/icons.json new file mode 100644 index 0000000..4b229f5 --- /dev/null +++ b/custom_components/smart_monitor/icons.json @@ -0,0 +1,127 @@ +{ + "entity": { + "binary_sensor": { + "health_problem": { + "default": "mdi:harddisk", + "state": { + "on": "mdi:harddisk-remove" + } + }, + "sector_problem": { + "default": "mdi:check-circle-outline", + "state": { + "on": "mdi:alert-circle" + } + }, + "self_test_running": { + "default": "mdi:clipboard-outline", + "state": { + "on": "mdi:clipboard-play-outline" + } + }, + "standby": { + "default": "mdi:harddisk", + "state": { + "on": "mdi:sleep" + } + } + }, + "button": { + "abort_self_test": { + "default": "mdi:stop-circle-outline" + }, + "self_test_conveyance": { + "default": "mdi:truck-check" + }, + "self_test_long": { + "default": "mdi:play-circle" + }, + "self_test_short": { + "default": "mdi:play-circle-outline" + } + }, + "sensor": { + "available_spare": { + "default": "mdi:battery-heart-variant" + }, + "command_timeouts": { + "default": "mdi:timer-off-outline" + }, + "data_read": { + "default": "mdi:download" + }, + "data_written": { + "default": "mdi:upload" + }, + "error_log_entries": { + "default": "mdi:format-list-text" + }, + "grown_defects": { + "default": "mdi:alert" + }, + "health": { + "default": "mdi:harddisk", + "state": { + "failed": "mdi:harddisk-remove", + "warning": "mdi:alert" + } + }, + "last_self_test": { + "default": "mdi:clipboard-outline", + "state": { + "aborted": "mdi:clipboard-remove-outline", + "failed": "mdi:clipboard-alert", + "passed": "mdi:clipboard-check" + } + }, + "media_errors": { + "default": "mdi:alert" + }, + "non_medium_errors": { + "default": "mdi:alert-circle-outline" + }, + "offline_uncorrectable": { + "default": "mdi:close-circle-outline" + }, + "pending_sectors": { + "default": "mdi:alert-outline" + }, + "percentage_used": { + "default": "mdi:chart-donut" + }, + "power_cycles": { + "default": "mdi:power" + }, + "power_on_hours": { + "default": "mdi:clock-outline" + }, + "reallocated_sectors": { + "default": "mdi:alert" + }, + "reallocation_events": { + "default": "mdi:alert" + }, + "reported_uncorrectable": { + "default": "mdi:close-circle" + }, + "spin_retry_count": { + "default": "mdi:rotate-right" + }, + "udma_crc_errors": { + "default": "mdi:lan-disconnect" + }, + "uncorrected_read_errors": { + "default": "mdi:book-alert-outline" + }, + "uncorrected_verify_errors": { + "default": "mdi:check-decagram-outline" + }, + "uncorrected_write_errors": { + "default": "mdi:pencil-off-outline" + }, + "unsafe_shutdowns": { + "default": "mdi:power-plug-off" + } + } + } +} diff --git a/custom_components/smart_monitor/manifest.json b/custom_components/smart_monitor/manifest.json new file mode 100644 index 0000000..2978ceb --- /dev/null +++ b/custom_components/smart_monitor/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "smart_monitor", + "name": "SMART Disk Monitor", + "codeowners": ["@ChristianHandy"], + "config_flow": true, + "documentation": "https://github.com/ChristianHandy/HAOS_SMART_Monitor", + "integration_type": "hub", + "iot_class": "local_polling", + "issue_tracker": "https://github.com/ChristianHandy/HAOS_SMART_Monitor/issues", + "loggers": ["aiosmartctl", "asyncssh"], + "requirements": ["aiosmartctl==0.1.0"], + "version": "2.0.0" +} diff --git a/custom_components/smart_monitor/sensor.py b/custom_components/smart_monitor/sensor.py new file mode 100644 index 0000000..a80f6b6 --- /dev/null +++ b/custom_components/smart_monitor/sensor.py @@ -0,0 +1,230 @@ +"""Sensors for SMART Disk Monitor.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import override + +from aiosmartctl import Disk, HealthStatus, SelfTestResult + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfInformation, + UnitOfTemperature, + UnitOfTime, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import SmartMonitorConfigEntry, SmartMonitorCoordinator +from .entity import ( + SmartMonitorEntity, + SmartMonitorEntityDescription, + async_setup_disk_entities, +) + +PARALLEL_UPDATES = 0 + +HEALTH_OPTIONS = [ + HealthStatus.PASSED.value, + HealthStatus.WARNING.value, + HealthStatus.FAILED.value, +] +SELF_TEST_OPTIONS = [ + SelfTestResult.PASSED.value, + SelfTestResult.FAILED.value, + SelfTestResult.ABORTED.value, + SelfTestResult.UNKNOWN.value, +] + + +@dataclass(frozen=True, kw_only=True) +class SmartMonitorSensorEntityDescription( + SensorEntityDescription, SmartMonitorEntityDescription +): + """Describes a disk sensor.""" + + value_fn: Callable[[Disk], int | str | None] + exists_fn: Callable[[Disk], bool] = lambda _: True + + +def _has[T](getter: Callable[[Disk], T | None]) -> Callable[[Disk], bool]: + return lambda disk: getter(disk) is not None + + +def _counter( + key: str, + getter: Callable[[Disk], int | None], + *, + enabled: bool = True, +) -> SmartMonitorSensorEntityDescription: + """Describe an error counter that only some disks report.""" + return SmartMonitorSensorEntityDescription( + key=key, + translation_key=key, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=enabled, + value_fn=getter, + exists_fn=_has(getter), + ) + + +def _last_self_test(disk: Disk) -> str | None: + if (entry := disk.last_self_test) is None: + return None + return entry.result.value + + +SENSORS: tuple[SmartMonitorSensorEntityDescription, ...] = ( + SmartMonitorSensorEntityDescription( + key="health", + translation_key="health", + device_class=SensorDeviceClass.ENUM, + options=HEALTH_OPTIONS, + value_fn=lambda disk: ( + None if disk.health is HealthStatus.UNKNOWN else disk.health.value + ), + ), + SmartMonitorSensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda disk: disk.temperature, + exists_fn=_has(lambda disk: disk.temperature), + ), + SmartMonitorSensorEntityDescription( + key="percentage_used", + translation_key="percentage_used", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda disk: disk.percentage_used, + exists_fn=_has(lambda disk: disk.percentage_used), + ), + SmartMonitorSensorEntityDescription( + key="power_on_hours", + translation_key="power_on_hours", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.HOURS, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda disk: disk.power_on_hours, + exists_fn=_has(lambda disk: disk.power_on_hours), + ), + SmartMonitorSensorEntityDescription( + key="power_cycles", + translation_key="power_cycles", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda disk: disk.power_cycles, + exists_fn=_has(lambda disk: disk.power_cycles), + ), + SmartMonitorSensorEntityDescription( + key="last_self_test", + translation_key="last_self_test", + device_class=SensorDeviceClass.ENUM, + options=SELF_TEST_OPTIONS, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=_last_self_test, + exists_fn=lambda disk: bool(disk.supported_self_tests or disk.self_tests), + ), + SmartMonitorSensorEntityDescription( + key="data_read", + translation_key="data_read", + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.TERABYTES, + suggested_display_precision=2, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda disk: disk.data_read_bytes, + exists_fn=_has(lambda disk: disk.data_read_bytes), + ), + SmartMonitorSensorEntityDescription( + key="data_written", + translation_key="data_written", + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.TERABYTES, + suggested_display_precision=2, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda disk: disk.data_written_bytes, + exists_fn=_has(lambda disk: disk.data_written_bytes), + ), + SmartMonitorSensorEntityDescription( + key="available_spare", + translation_key="available_spare", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda disk: disk.available_spare, + exists_fn=_has(lambda disk: disk.available_spare), + ), + _counter("reallocated_sectors", lambda disk: disk.reallocated_sectors), + _counter("pending_sectors", lambda disk: disk.pending_sectors), + _counter("offline_uncorrectable", lambda disk: disk.offline_uncorrectable), + _counter("udma_crc_errors", lambda disk: disk.udma_crc_errors), + _counter( + "reported_uncorrectable", + lambda disk: disk.reported_uncorrectable, + enabled=False, + ), + _counter( + "reallocation_events", lambda disk: disk.reallocation_events, enabled=False + ), + _counter("command_timeouts", lambda disk: disk.command_timeouts, enabled=False), + _counter("spin_retry_count", lambda disk: disk.spin_retry_count, enabled=False), + _counter("media_errors", lambda disk: disk.media_errors), + _counter("unsafe_shutdowns", lambda disk: disk.unsafe_shutdowns, enabled=False), + _counter("error_log_entries", lambda disk: disk.error_log_entries, enabled=False), + _counter("grown_defects", lambda disk: disk.grown_defects), + _counter("uncorrected_read_errors", lambda disk: disk.uncorrected_read_errors), + _counter("uncorrected_write_errors", lambda disk: disk.uncorrected_write_errors), + _counter( + "uncorrected_verify_errors", + lambda disk: disk.uncorrected_verify_errors, + enabled=False, + ), + _counter("non_medium_errors", lambda disk: disk.non_medium_errors, enabled=False), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmartMonitorConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up sensors for all disks.""" + async_setup_disk_entities( + entry.runtime_data, async_add_entities, SENSORS, SmartMonitorSensorEntity + ) + + +class SmartMonitorSensorEntity(SmartMonitorEntity, SensorEntity): + """A SMART value of a disk.""" + + entity_description: SmartMonitorSensorEntityDescription + + def __init__( + self, + coordinator: SmartMonitorCoordinator, + disk_id: str, + description: SmartMonitorSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, disk_id, description) + + @property + @override + def native_value(self) -> int | str | None: + """Return the value.""" + return self.entity_description.value_fn(self.disk) diff --git a/custom_components/smart_monitor/strings.json b/custom_components/smart_monitor/strings.json new file mode 100644 index 0000000..167a79e --- /dev/null +++ b/custom_components/smart_monitor/strings.json @@ -0,0 +1,231 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "different_host": "The SSH host key belongs to a different host than the one configured." + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "host_key_changed": "The host presented a different SSH host key than the one that was confirmed.", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_private_key": "The private key could not be read. Paste an unencrypted OpenSSH or PEM private key.", + "missing_credentials": "Enter a password, a private key, or both.", + "smartctl_not_found": "smartctl was not found on the host. Install smartmontools.", + "sudo_failed": "smartctl could not be run with sudo. Allow the user to run smartctl with sudo without a password.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "confirm_host_key": { + "description": "{host} presented an SSH host key with this fingerprint:\n\n`{fingerprint}`\n\nCompare it with the output of `ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub` on the host. Your credentials are only sent once you confirm.", + "title": "Confirm the host key" + }, + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "private_key": "[%key:component::smart_monitor::config::step::user::data::private_key%]" + }, + "data_description": { + "password": "[%key:component::smart_monitor::config::step::user::data_description::password%]", + "private_key": "[%key:component::smart_monitor::config::step::user::data_description::private_key%]" + }, + "description": "Enter the credentials for {host}. Its SSH host key has the fingerprint `{fingerprint}`.", + "title": "[%key:common::config_flow::title::reauth%]" + }, + "reauth_host_key_changed": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "private_key": "[%key:component::smart_monitor::config::step::user::data::private_key%]" + }, + "data_description": { + "password": "[%key:component::smart_monitor::config::step::user::data_description::password%]", + "private_key": "[%key:component::smart_monitor::config::step::user::data_description::private_key%]" + }, + "description": "The SSH host key of {host} changed. Its new fingerprint is `{fingerprint}`.\n\nOnly continue if you reinstalled the host or regenerated its SSH keys. Otherwise someone may be intercepting the connection.", + "title": "The host key changed" + }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "use_sudo": "[%key:component::smart_monitor::config::step::user::data::use_sudo%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "[%key:component::smart_monitor::config::step::user::data_description::host%]", + "port": "[%key:component::smart_monitor::config::step::user::data_description::port%]", + "use_sudo": "[%key:component::smart_monitor::config::step::user::data_description::use_sudo%]", + "username": "[%key:component::smart_monitor::config::step::user::data_description::username%]" + } + }, + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]", + "port": "[%key:common::config_flow::data::port%]", + "private_key": "Private key", + "use_sudo": "Run smartctl with sudo", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "host": "The hostname or IP address of the server whose disks to monitor.", + "password": "The SSH password. Leave empty when you use a private key.", + "port": "The SSH port of the server.", + "private_key": "An unencrypted SSH private key in OpenSSH or PEM format. Leave empty when you use a password.", + "use_sudo": "Enable when the user is not root and may run smartctl with sudo without a password.", + "username": "The user to log in as." + }, + "description": "Monitor the SMART health of the disks of a Linux, Unraid, Proxmox VE, TrueNAS or other server over SSH. The server needs smartmontools 7 or newer." + } + } + }, + "entity": { + "binary_sensor": { + "health_problem": { + "name": "Health problem" + }, + "sector_problem": { + "name": "Bad sectors" + }, + "self_test_running": { + "name": "Self-test running" + }, + "standby": { + "name": "Standby" + } + }, + "button": { + "abort_self_test": { + "name": "Abort self-test" + }, + "self_test_conveyance": { + "name": "Start conveyance self-test" + }, + "self_test_long": { + "name": "Start long self-test" + }, + "self_test_short": { + "name": "Start short self-test" + } + }, + "sensor": { + "available_spare": { + "name": "Available spare" + }, + "command_timeouts": { + "name": "Command timeouts" + }, + "data_read": { + "name": "Data read" + }, + "data_written": { + "name": "Data written" + }, + "error_log_entries": { + "name": "Error log entries" + }, + "grown_defects": { + "name": "Grown defects" + }, + "health": { + "name": "Health", + "state": { + "failed": "Failed", + "passed": "Passed", + "warning": "Warning" + } + }, + "last_self_test": { + "name": "Last self-test", + "state": { + "aborted": "Aborted", + "failed": "Failed", + "passed": "Passed", + "unknown": "Unknown" + } + }, + "media_errors": { + "name": "Media errors" + }, + "non_medium_errors": { + "name": "Non-medium errors" + }, + "offline_uncorrectable": { + "name": "Offline uncorrectable sectors" + }, + "pending_sectors": { + "name": "Pending sectors" + }, + "percentage_used": { + "name": "Endurance used" + }, + "power_cycles": { + "name": "Power cycles" + }, + "power_on_hours": { + "name": "Power-on time" + }, + "reallocated_sectors": { + "name": "Reallocated sectors" + }, + "reallocation_events": { + "name": "Reallocation events" + }, + "reported_uncorrectable": { + "name": "Reported uncorrectable errors" + }, + "spin_retry_count": { + "name": "Spin retries" + }, + "udma_crc_errors": { + "name": "Interface CRC errors" + }, + "uncorrected_read_errors": { + "name": "Uncorrected read errors" + }, + "uncorrected_verify_errors": { + "name": "Uncorrected verify errors" + }, + "uncorrected_write_errors": { + "name": "Uncorrected write errors" + }, + "unsafe_shutdowns": { + "name": "Unsafe shutdowns" + } + } + }, + "exceptions": { + "host_key_changed": { + "message": "The SSH host key of the server changed." + }, + "host_key_missing": { + "message": "The SSH host key of the server has not been confirmed yet." + }, + "invalid_auth": { + "message": "The server rejected the SSH credentials." + }, + "self_test_failed": { + "message": "Could not control the self-test of {device}: {error}" + }, + "update_failed": { + "message": "Could not read SMART data: {error}" + } + }, + "options": { + "error": { + "invalid_device": "Each line must be a device path, optionally followed by a smartctl device type, for example `/dev/bus/0 megaraid,3`." + }, + "step": { + "init": { + "data": { + "devices": "Devices", + "skip_standby": "Do not wake up sleeping disks" + }, + "data_description": { + "devices": "Disks to read instead of discovering them, one per line. Use this for RAID controllers smartctl cannot scan, for example `/dev/sg0 cciss,0`.", + "skip_standby": "Keep the last values of disks in standby instead of spinning them up." + } + } + } + } +} diff --git a/custom_components/smart_monitor/translations/en.json b/custom_components/smart_monitor/translations/en.json new file mode 100644 index 0000000..34ba3f0 --- /dev/null +++ b/custom_components/smart_monitor/translations/en.json @@ -0,0 +1,231 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "cannot_connect": "Failed to connect", + "different_host": "The SSH host key belongs to a different host than the one configured." + }, + "error": { + "cannot_connect": "Failed to connect", + "host_key_changed": "The host presented a different SSH host key than the one that was confirmed.", + "invalid_auth": "Invalid authentication", + "invalid_private_key": "The private key could not be read. Paste an unencrypted OpenSSH or PEM private key.", + "missing_credentials": "Enter a password, a private key, or both.", + "smartctl_not_found": "smartctl was not found on the host. Install smartmontools.", + "sudo_failed": "smartctl could not be run with sudo. Allow the user to run smartctl with sudo without a password.", + "unknown": "Unexpected error" + }, + "step": { + "confirm_host_key": { + "description": "{host} presented an SSH host key with this fingerprint:\n\n`{fingerprint}`\n\nCompare it with the output of `ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub` on the host. Your credentials are only sent once you confirm.", + "title": "Confirm the host key" + }, + "reauth_confirm": { + "data": { + "password": "Password", + "private_key": "Private key" + }, + "data_description": { + "password": "The SSH password. Leave empty when you use a private key.", + "private_key": "An unencrypted SSH private key in OpenSSH or PEM format. Leave empty when you use a password." + }, + "description": "Enter the credentials for {host}. Its SSH host key has the fingerprint `{fingerprint}`.", + "title": "Authentication expired for {name}" + }, + "reauth_host_key_changed": { + "data": { + "password": "Password", + "private_key": "Private key" + }, + "data_description": { + "password": "The SSH password. Leave empty when you use a private key.", + "private_key": "An unencrypted SSH private key in OpenSSH or PEM format. Leave empty when you use a password." + }, + "description": "The SSH host key of {host} changed. Its new fingerprint is `{fingerprint}`.\n\nOnly continue if you reinstalled the host or regenerated its SSH keys. Otherwise someone may be intercepting the connection.", + "title": "The host key changed" + }, + "reconfigure": { + "data": { + "host": "Host", + "port": "Port", + "use_sudo": "Run smartctl with sudo", + "username": "Username" + }, + "data_description": { + "host": "The hostname or IP address of the server whose disks to monitor.", + "port": "The SSH port of the server.", + "use_sudo": "Enable when the user is not root and may run smartctl with sudo without a password.", + "username": "The user to log in as." + } + }, + "user": { + "data": { + "host": "Host", + "password": "Password", + "port": "Port", + "private_key": "Private key", + "use_sudo": "Run smartctl with sudo", + "username": "Username" + }, + "data_description": { + "host": "The hostname or IP address of the server whose disks to monitor.", + "password": "The SSH password. Leave empty when you use a private key.", + "port": "The SSH port of the server.", + "private_key": "An unencrypted SSH private key in OpenSSH or PEM format. Leave empty when you use a password.", + "use_sudo": "Enable when the user is not root and may run smartctl with sudo without a password.", + "username": "The user to log in as." + }, + "description": "Monitor the SMART health of the disks of a Linux, Unraid, Proxmox VE, TrueNAS or other server over SSH. The server needs smartmontools 7 or newer." + } + } + }, + "entity": { + "binary_sensor": { + "health_problem": { + "name": "Health problem" + }, + "sector_problem": { + "name": "Bad sectors" + }, + "self_test_running": { + "name": "Self-test running" + }, + "standby": { + "name": "Standby" + } + }, + "button": { + "abort_self_test": { + "name": "Abort self-test" + }, + "self_test_conveyance": { + "name": "Start conveyance self-test" + }, + "self_test_long": { + "name": "Start long self-test" + }, + "self_test_short": { + "name": "Start short self-test" + } + }, + "sensor": { + "available_spare": { + "name": "Available spare" + }, + "command_timeouts": { + "name": "Command timeouts" + }, + "data_read": { + "name": "Data read" + }, + "data_written": { + "name": "Data written" + }, + "error_log_entries": { + "name": "Error log entries" + }, + "grown_defects": { + "name": "Grown defects" + }, + "health": { + "name": "Health", + "state": { + "failed": "Failed", + "passed": "Passed", + "warning": "Warning" + } + }, + "last_self_test": { + "name": "Last self-test", + "state": { + "aborted": "Aborted", + "failed": "Failed", + "passed": "Passed", + "unknown": "Unknown" + } + }, + "media_errors": { + "name": "Media errors" + }, + "non_medium_errors": { + "name": "Non-medium errors" + }, + "offline_uncorrectable": { + "name": "Offline uncorrectable sectors" + }, + "pending_sectors": { + "name": "Pending sectors" + }, + "percentage_used": { + "name": "Endurance used" + }, + "power_cycles": { + "name": "Power cycles" + }, + "power_on_hours": { + "name": "Power-on time" + }, + "reallocated_sectors": { + "name": "Reallocated sectors" + }, + "reallocation_events": { + "name": "Reallocation events" + }, + "reported_uncorrectable": { + "name": "Reported uncorrectable errors" + }, + "spin_retry_count": { + "name": "Spin retries" + }, + "udma_crc_errors": { + "name": "Interface CRC errors" + }, + "uncorrected_read_errors": { + "name": "Uncorrected read errors" + }, + "uncorrected_verify_errors": { + "name": "Uncorrected verify errors" + }, + "uncorrected_write_errors": { + "name": "Uncorrected write errors" + }, + "unsafe_shutdowns": { + "name": "Unsafe shutdowns" + } + } + }, + "exceptions": { + "host_key_changed": { + "message": "The SSH host key of the server changed." + }, + "host_key_missing": { + "message": "The SSH host key of the server has not been confirmed yet." + }, + "invalid_auth": { + "message": "The server rejected the SSH credentials." + }, + "self_test_failed": { + "message": "Could not control the self-test of {device}: {error}" + }, + "update_failed": { + "message": "Could not read SMART data: {error}" + } + }, + "options": { + "error": { + "invalid_device": "Each line must be a device path, optionally followed by a smartctl device type, for example `/dev/bus/0 megaraid,3`." + }, + "step": { + "init": { + "data": { + "devices": "Devices", + "skip_standby": "Do not wake up sleeping disks" + }, + "data_description": { + "devices": "Disks to read instead of discovering them, one per line. Use this for RAID controllers smartctl cannot scan, for example `/dev/sg0 cciss,0`.", + "skip_standby": "Keep the last values of disks in standby instead of spinning them up." + } + } + } + } +} diff --git a/docs/CORE_SUBMISSION.md b/docs/CORE_SUBMISSION.md new file mode 100644 index 0000000..fb1396a --- /dev/null +++ b/docs/CORE_SUBMISSION.md @@ -0,0 +1,116 @@ +# Getting SMART Disk Monitor into Home Assistant core + +This repository is set up so the integration can be copied into Home +Assistant core unchanged. This page lists what is done, what only the +maintainer can do, and the order of the pull requests. + +## Status + +`script/run_core_checks.sh` runs core's own checks against the integration +inside a core checkout (2026.10.2). Both variants pass: all three platforms, +and the sensor-only variant for the first pull request. + +| Check | Result | +|---|---| +| hassfest (manifest, translations, icons, config flow, quality scale, mypy config) | ✅ | +| `gen_requirements_all` | ✅ | +| ruff check and ruff format with core's configuration | ✅ | +| codespell, yamllint, prettier, JSON | ✅ | +| mypy with core's strict settings | ✅ | +| pylint with Home Assistant's plugins | ✅ | +| pytest with core's test harness (translation and lingering-task checks) | ✅ 100% coverage | + +The quality scale file (`docs/core/quality_scale.yaml`) claims every rule up +to platinum except two that are still **todo**. The check script marks them +done in its throwaway checkout only, so it can validate everything else. + +## What only you can do + +1. **Publish the library to PyPI** (`dependency-transparency`). Core only + accepts dependencies released on PyPI from a public repository by CI. + 1. On pypi.org, add a *pending trusted publisher* for the project + `aiosmartctl`: owner `ChristianHandy`, repository + `HAOS_SMART_Monitor`, workflow `release-library.yml`, environment + `pypi`. + 2. Create the `pypi` environment in the repository settings. + 3. Push the tag `aiosmartctl-v0.1.0`. The workflow checks that the tag + matches `__version__`, builds and publishes. + + Reviewers may ask for the library to live in its own repository. If so, + move `library/` to `ChristianHandy/aiosmartctl` and keep the same + workflow. + +2. **Add brand images** (`brands`). Open a pull request to + [home-assistant/brands](https://github.com/home-assistant/brands) adding + `core_integrations/smart_monitor/icon.png` (256×256) and `icon@2x.png` + (512×512), plus `logo.png` if you have one. Then change `brands` to + `done` in `docs/core/quality_scale.yaml`. + +3. **Test on real hardware.** Reviewers will ask whether you run the + integration yourself. Version 2.0 has only been tested against sample + output and a simulated SSH server; see the hardware table in the README. + Run it at least on your own Unraid/Proxmox hosts, ideally with a SAS or + RAID controller disk, and fix anything that differs. + +4. **Sign the CLA** when the Home Assistant bot asks on your first pull + request. + +## Decide before the first pull request + +- **Name and domain.** Core names integrations after the product or + protocol. Reviewers may prefer *smartmontools* (domain `smartmontools`) + over *SMART Disk Monitor* / `smart_monitor`. Renaming later means everyone + sets the integration up again, so settle this in the first review. Rename + with a search-and-replace of `smart_monitor` and the integration name. +- **Scope.** SSH plus smartctl is a single, clear scope. Keep other + protocols, such as Redfish for iDRAC/iLO, for a separate integration. + +## Pull requests, in order + +New integrations may only add **one platform** in their first pull request. + +1. **Core: integration with the sensor platform.** + + ```bash + git clone https://github.com//core && cd core + git checkout -b smart-monitor + python /path/to/this/repo/script/port_to_core.py . --platforms sensor + python -m script.hassfest + python -m script.gen_requirements_all + # add homeassistant.components.smart_monitor.* to .strict-typing + ``` + + The quality scale in the copy says `bronze`; leave it at that for a new + integration. Fill in the core pull request template; link the docs pull + request and the library's PyPI page. + +2. **Docs:** add `source/_integrations/smart_monitor.markdown` to + [home-assistant.io](https://github.com/home-assistant/home-assistant.io) + from `docs/home-assistant-io/smart_monitor.markdown`, targeting the + `next` branch. + +3. **Core: binary sensor platform**, after the first one is merged: + `--platforms sensor binary_sensor`. + +4. **Core: button platform**: all platforms. + +5. Raise the quality scale in the manifest as rules are reviewed (silver, + gold, platinum). + +## Things reviewers are likely to ask about + +- The host key confirmation step is unusual; it exists because the + password is sent to the host. `sftp_storage` and `asuswrt` show that SSH + integrations are accepted. +- Entities are only created for values a disk reports, and devices of + disks that are gone are not removed automatically, because a dead disk + must stay visible. Users can delete them (`async_remove_config_entry_device`). +- `async_setup_entry` starts reauthentication when no host key is stored. + In core this never happens; it exists for entries from the custom + integration. It can be removed from the core copy if reviewers ask. + +## Keeping the custom integration and core in step + +Develop here, then copy with `port_to_core.py`. The script rewrites imports, +drops `# BEGIN CUSTOM-ONLY` blocks from the tests, adapts the manifest and +removes platforms that are not part of a pull request. diff --git a/docs/core/quality_scale.yaml b/docs/core/quality_scale.yaml new file mode 100644 index 0000000..2a772cc --- /dev/null +++ b/docs/core/quality_scale.yaml @@ -0,0 +1,82 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: The integration has no actions. + appropriate-polling: done + brands: todo + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: todo + docs-actions: + status: exempt + comment: The integration has no actions. + docs-conditions: + status: exempt + comment: The integration has no conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: The integration has no triggers. + entity-event-setup: + status: exempt + comment: Entities do not subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: SSH servers cannot be discovered. + discovery: + status: exempt + comment: SSH servers cannot be discovered. + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: + status: exempt + comment: Problems are reported through reauthentication and unavailable entities. + stale-devices: + status: done + comment: Disks that are gone can be removed by the user; they are not removed automatically because a failed disk must stay visible. + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: The integration uses SSH, not HTTP. + strict-typing: done diff --git a/docs/home-assistant-io/smart_monitor.markdown b/docs/home-assistant-io/smart_monitor.markdown new file mode 100644 index 0000000..da1f018 --- /dev/null +++ b/docs/home-assistant-io/smart_monitor.markdown @@ -0,0 +1,174 @@ +--- +title: SMART Disk Monitor +description: Instructions on how to monitor the SMART health of server disks over SSH in Home Assistant. +ha_category: + - Sensor + - System monitor +ha_release: "2026.12" +ha_iot_class: Local Polling +ha_config_flow: true +ha_codeowners: + - '@ChristianHandy' +ha_domain: smart_monitor +ha_platforms: + - binary_sensor + - button + - diagnostics + - sensor +ha_integration_type: hub +ha_quality_scale: bronze +--- + +The **SMART Disk Monitor** {% term integration %} shows the health of the disks in your servers. It logs in to a server over SSH, runs [smartmontools](https://www.smartmontools.org/) `smartctl`, and creates a {% term device %} for every disk with its health, temperature, wear, error counters and self-test results. + +Use it to get a notification before a disk fails, to watch NAS temperatures, or to schedule self-tests. + +## Supported devices + +Any server you can log in to over SSH with smartmontools 7.0 or newer installed, for example Unraid, Proxmox VE, TrueNAS, Debian, Ubuntu, or FreeBSD. It reads: + +- SATA hard disks and SSDs +- NVMe SSDs +- SAS hard disks and SSDs +- Disks behind RAID controllers that smartctl can pass commands through, such as LSI MegaRAID and Dell PERC. Disks behind HP Smart Array, Areca, 3ware, and Adaptec controllers can be added by hand. + +Not every combination of controller, firmware, and operating system has been tested on real hardware. Disks behind RAID controllers and SAS disks have been tested with sample output only. + +## Prerequisites + +On every server: + +1. Install smartmontools, for example `sudo apt install smartmontools`. Unraid and Proxmox VE include it. +2. Use the `root` user, or a user that may run smartctl with sudo without a password: + + ```bash + echo 'ha-smart ALL=(root) NOPASSWD: /usr/sbin/smartctl' | sudo tee /etc/sudoers.d/ha-smart + ``` + +{% include integrations/config_flow.md %} + +{% configuration_basic %} +Host: + description: The hostname or IP address of the server. +Port: + description: The SSH port of the server. +Username: + description: The user to log in as. +Password: + description: The SSH password. Leave empty when you use a private key. +Private key: + description: An unencrypted SSH private key in OpenSSH or PEM format. Leave empty when you use a password. +Run smartctl with sudo: + description: Enable when the user is not root and may run smartctl with sudo without a password. +{% endconfiguration_basic %} + +After you enter these, Home Assistant shows the fingerprint of the server's SSH host key. Compare it with the output of `ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub` on the server before you confirm. Your credentials are only sent after you confirm. + +{% include integrations/option_flow.md %} + +{% configuration_basic %} +Do not wake up sleeping disks: + description: Keep the last values of disks in standby instead of spinning them up. Enabled by default. +Devices: + description: "Disks to read instead of discovering them, one per line: a device path, optionally followed by a smartctl device type. For example `/dev/bus/0 megaraid,3` or `/dev/sg0 cciss,0`." +{% endconfiguration_basic %} + +## Data updates + +The integration reads all disks every 5 minutes. Disks in standby are not woken up unless you turn off **Do not wake up sleeping disks**. + +## Sensors + +Entities are only created for values a disk reports. + +- **Health**: Passed, Warning, or Failed. Warning means an attribute crossed its threshold or an NVMe disk reports a critical warning. +- **Temperature** +- **Endurance used**: Percentage of the rated endurance an SSD has used. +- **Last self-test**: Passed, Failed, Aborted, or Unknown. +- **Power-on time**, **Power cycles**, **Data read**, **Data written** +- Error counters, depending on the disk type: reallocated, pending, and offline uncorrectable sectors; interface CRC errors; available spare and media errors (NVMe); grown defects and uncorrected read, write, and verify errors (SAS). Rarely needed counters are disabled by default. + +## Binary sensors + +- **Health problem**: On when the health is Warning or Failed. +- **Bad sectors**: On when the disk reports reallocated, pending, or uncorrectable sectors, grown defects, or media errors. +- **Self-test running** +- **Standby**: On while the disk sleeps and shows its last values. + +## Buttons + +- **Start short self-test**, **Start long self-test**, **Start conveyance self-test**: Only the tests the disk supports. +- **Abort self-test** + +## Examples + +### Get notified when a disk has a problem + +{% raw %} + +```yaml +automation: + - alias: "Disk problem" + triggers: + - trigger: state + entity_id: binary_sensor.wdc_wd40efrx_68n32n0_health_problem + to: "on" + actions: + - action: notify.mobile_app_phone + data: + message: "{{ trigger.to_state.name }} reports a problem" +``` + +{% endraw %} + +### Run a long self-test every month + +{% raw %} + +```yaml +automation: + - alias: "Monthly disk self-test" + triggers: + - trigger: time + at: "03:00:00" + conditions: + - condition: template + value_template: "{{ now().day == 1 }}" + actions: + - action: button.press + target: + entity_id: button.wdc_wd40efrx_68n32n0_start_long_self_test +``` + +{% endraw %} + +## Known limitations + +- Hardware RAID volumes report no SMART data. The physical disks behind the controller are shown when the controller supports pass-through. +- Disks that report neither a serial number nor a WWN, such as some USB enclosures, are identified by their device path. If the path changes, they show up as a new device. +- A disk that is asleep or unreadable when Home Assistant starts appears once it has been read. +- Encrypted private keys are not supported. + +## Troubleshooting + +### smartctl was not found + +Install smartmontools on the server. + +### Disks are unavailable with "Permission denied" + +Log in as root, or enable **Run smartctl with sudo** and allow the user to run smartctl with sudo without a password. + +### A disk behind a RAID controller is missing + +Run `smartctl --scan-open` on the server. If the disk is not listed, add it under **Devices** in the options. + +### The SSH host key changed + +If you reinstalled the server or regenerated its SSH keys, confirm the new fingerprint when Home Assistant asks you to. Otherwise, find out why the key changed before you continue. + +## Removing the integration + +This integration follows standard integration removal. Nothing is left on the server. + +{% include integrations/remove_device_service_steps.md %} diff --git a/hacs.json b/hacs.json new file mode 100644 index 0000000..0f77d2a --- /dev/null +++ b/hacs.json @@ -0,0 +1,5 @@ +{ + "name": "SMART Disk Monitor", + "homeassistant": "2026.10.0", + "render_readme": true +} diff --git a/library/LICENSE b/library/LICENSE new file mode 100644 index 0000000..2cc7eac --- /dev/null +++ b/library/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Christian + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/library/README.md b/library/README.md new file mode 100644 index 0000000..8e077b5 --- /dev/null +++ b/library/README.md @@ -0,0 +1,71 @@ +# aiosmartctl + +Asynchronous Python client that reads SMART disk health from remote hosts by +running [smartmontools](https://www.smartmontools.org/) `smartctl` over SSH. +It powers the SMART Disk Monitor integration for Home Assistant. + +- SATA/ATA hard disks and SSDs, NVMe, and SAS/SCSI disks +- Disks behind RAID controllers and HBAs that smartctl can pass through + (MegaRAID/PERC, HP Smart Array `cciss`, Areca, 3ware, aacraid) and USB bridges +- Discovery with `smartctl --scan-open`, falling back to `lsblk` +- Does not wake disks in standby unless asked to +- Optional `sudo -n` for non-root users +- SSH host key pinning, password or private key authentication +- Falls back to text parsing for smartctl 6.x, which has no JSON output + +## Usage + +```python +import asyncio + +from aiosmartctl import SmartctlClient, fetch_host_key, host_key_fingerprint + + +async def main() -> None: + host_key = await fetch_host_key("nas.local") + print("Trust this host key?", host_key_fingerprint(host_key)) + + client = SmartctlClient( + "nas.local", + username="root", + password="secret", + known_host_key=host_key, + ) + async with client.session() as session: + for disk in await session.get_disks(): + print(disk.spec.key, disk.model, disk.health, disk.temperature) + + +asyncio.run(main()) +``` + +Disks the controller hides from `--scan-open`, such as disks behind an HP +Smart Array, can be read by passing device specifications explicitly: + +```python +from aiosmartctl import DeviceSpec + +specs = [DeviceSpec.parse("/dev/sg0 cciss,0"), DeviceSpec.parse("/dev/sg0 cciss,1")] +disks = await session.get_disks(specs) +``` + +## Hardware test status + +The parsers are tested against smartctl 7.x JSON output for each disk +family, and the SSH layer against an in-process SSH server. Not every +combination has been tested against real hardware. In particular, SAS disks, +RAID controller pass-through (MegaRAID, cciss, Areca, 3ware, aacraid), +FreeBSD/TrueNAS CORE hosts and smartctl 6.x have only been checked against +sample output. Reports and `smartctl --json -a` captures from such systems +are welcome. + +## Development + +```bash +pip install -e ".[test]" +pytest --cov +``` + +## License + +MIT diff --git a/library/pyproject.toml b/library/pyproject.toml new file mode 100644 index 0000000..7acac88 --- /dev/null +++ b/library/pyproject.toml @@ -0,0 +1,51 @@ +[build-system] +requires = ["hatchling>=1.25"] +build-backend = "hatchling.build" + +[project] +name = "aiosmartctl" +dynamic = ["version"] +description = "Asynchronous client for reading SMART disk data with smartctl over SSH" +readme = "README.md" +license = "MIT" +license-files = ["LICENSE"] +authors = [{ name = "Christian Handy" }] +requires-python = ">=3.13" +dependencies = ["asyncssh>=2.17.0"] +keywords = ["smart", "smartctl", "smartmontools", "ssh", "disk", "home-assistant"] +classifiers = [ + "Development Status :: 4 - Beta", + "Framework :: AsyncIO", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: System :: Hardware", + "Topic :: System :: Monitoring", + "Typing :: Typed", +] + +[project.urls] +Homepage = "https://github.com/ChristianHandy/HAOS_SMART_Monitor/tree/main/library" +Issues = "https://github.com/ChristianHandy/HAOS_SMART_Monitor/issues" + +[project.optional-dependencies] +test = ["pytest>=8", "pytest-asyncio>=0.24", "pytest-cov>=5"] + +[tool.hatch.version] +path = "src/aiosmartctl/__init__.py" + +[tool.hatch.build.targets.wheel] +packages = ["src/aiosmartctl"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +testpaths = ["tests"] + +[tool.coverage.run] +source = ["aiosmartctl"] + +[tool.ruff.lint] +select = ["B", "E", "F", "I", "RUF", "SIM", "UP", "W"] diff --git a/library/src/aiosmartctl/__init__.py b/library/src/aiosmartctl/__init__.py new file mode 100644 index 0000000..ceb8805 --- /dev/null +++ b/library/src/aiosmartctl/__init__.py @@ -0,0 +1,61 @@ +"""Asynchronous client for reading SMART data with smartctl over SSH.""" + +from .client import ( + CommandResult, + CommandRunner, + SmartctlClient, + SmartctlSession, + fetch_host_key, + host_key_fingerprint, + validate_private_key, +) +from .exceptions import ( + SmartctlAuthenticationError, + SmartctlCommandError, + SmartctlConnectionError, + SmartctlError, + SmartctlHostKeyError, + SmartctlNotFoundError, + SmartctlPermissionError, +) +from .models import ( + AtaAttribute, + DeviceSpec, + Disk, + DiskType, + HealthStatus, + HostInfo, + Interface, + SelfTestEntry, + SelfTestResult, + SelfTestType, +) + +__version__ = "0.1.0" + +__all__ = [ + "AtaAttribute", + "CommandResult", + "CommandRunner", + "DeviceSpec", + "Disk", + "DiskType", + "HealthStatus", + "HostInfo", + "Interface", + "SelfTestEntry", + "SelfTestResult", + "SelfTestType", + "SmartctlAuthenticationError", + "SmartctlClient", + "SmartctlCommandError", + "SmartctlConnectionError", + "SmartctlError", + "SmartctlHostKeyError", + "SmartctlNotFoundError", + "SmartctlPermissionError", + "SmartctlSession", + "fetch_host_key", + "host_key_fingerprint", + "validate_private_key", +] diff --git a/library/src/aiosmartctl/client.py b/library/src/aiosmartctl/client.py new file mode 100644 index 0000000..e45547e --- /dev/null +++ b/library/src/aiosmartctl/client.py @@ -0,0 +1,381 @@ +"""Run smartctl on a remote host over SSH.""" + +from __future__ import annotations + +import asyncio +import logging +import shlex +from collections.abc import AsyncIterator, Awaitable, Callable, Sequence +from contextlib import asynccontextmanager +from dataclasses import dataclass + +import asyncssh + +from .exceptions import ( + SmartctlAuthenticationError, + SmartctlCommandError, + SmartctlConnectionError, + SmartctlHostKeyError, + SmartctlNotFoundError, + SmartctlPermissionError, +) +from .models import DeviceSpec, Disk, HostInfo, SelfTestType +from .parser import ( + EXIT_COMMAND_LINE, + EXIT_DEVICE_OPEN, + deduplicate, + parse_host_info, + parse_lsblk, + parse_scan, + parse_smartctl, + parse_smartctl_version, +) + +_LOGGER = logging.getLogger(__name__) + +# Many NAS distributions keep smartctl in an sbin directory that is not on the +# PATH of non-interactive SSH sessions. +_EXTRA_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/sbin" + +_HOST_INFO_SCRIPT = """ +echo "HOSTNAME=$(hostname 2>/dev/null || uname -n)" +echo "UNAME_S=$(uname -s)" +echo "UNAME_R=$(uname -r)" +cat /etc/os-release 2>/dev/null +[ -r /etc/unraid-version ] && sed -n 's/^version=/UNRAID_VERSION=/p' /etc/unraid-version +command -v pveversion >/dev/null 2>&1 && echo "PVE_VERSION=$(pveversion | cut -d/ -f2)" +true +""" + + +@dataclass(frozen=True, kw_only=True) +class CommandResult: + """Result of a command run on the host.""" + + exit_status: int + stdout: str + stderr: str + + +type CommandRunner = Callable[[str], Awaitable[CommandResult]] + + +def host_key_fingerprint(host_key: str) -> str: + """Return the SHA256 fingerprint of an OpenSSH formatted public key.""" + try: + return asyncssh.import_public_key(host_key).get_fingerprint("sha256") + except (asyncssh.KeyImportError, ValueError) as err: + raise ValueError(f"Invalid host key: {err}") from err + + +def validate_private_key(private_key: str, passphrase: str | None = None) -> None: + """Raise ValueError when the private key cannot be loaded.""" + try: + asyncssh.import_private_key(private_key, passphrase) + except (asyncssh.KeyImportError, ValueError) as err: + raise ValueError(str(err)) from err + + +async def fetch_host_key(host: str, port: int = 22, timeout: float = 15) -> str: + """Return the host key a server presents, in OpenSSH format. + + The key is not verified. Show its fingerprint to the user before trusting + it, and pass it as ``known_host_key`` from then on. + """ + try: + async with asyncio.timeout(timeout): + key = await asyncssh.get_server_host_key(host, port, config=None) + except (OSError, TimeoutError, asyncssh.Error) as err: + raise SmartctlConnectionError( + f"Cannot connect to {host}:{port}: {err}" + ) from err + if key is None: + raise SmartctlConnectionError(f"{host}:{port} did not present a host key") + return key.export_public_key("openssh").decode().strip() + + +class SmartctlClient: + """Connection settings for a host whose disks are read with smartctl.""" + + def __init__( + self, + host: str, + *, + username: str, + port: int = 22, + password: str | None = None, + private_key: str | None = None, + private_key_passphrase: str | None = None, + known_host_key: str | None, + use_sudo: bool = False, + connect_timeout: float = 15, + command_timeout: float = 120, + max_parallel: int = 4, + ) -> None: + """Initialize the client. + + ``known_host_key`` is the OpenSSH public key the host must present. + Passing ``None`` disables host key verification; only do that to + fetch a key the user then confirms. + """ + self.host = host + self.port = port + self.username = username + self._password = password + self._private_key = private_key + self._private_key_passphrase = private_key_passphrase + self.known_host_key = known_host_key + self.use_sudo = use_sudo + self.connect_timeout = connect_timeout + self.command_timeout = command_timeout + self.max_parallel = max_parallel + + @asynccontextmanager + async def session(self) -> AsyncIterator[SmartctlSession]: + """Open an SSH connection and yield a session that uses it.""" + client_keys = None + if self._private_key: + try: + client_keys = [ + asyncssh.import_private_key( + self._private_key, self._private_key_passphrase + ) + ] + except (asyncssh.KeyImportError, ValueError) as err: + raise SmartctlAuthenticationError( + f"Invalid private key: {err}" + ) from err + + known_hosts: ( + tuple[list[asyncssh.SSHKey], list[asyncssh.SSHKey], list[asyncssh.SSHKey]] + | None + ) = None + if self.known_host_key: + known_hosts = ([asyncssh.import_public_key(self.known_host_key)], [], []) + + try: + connection = await asyncssh.connect( + self.host, + port=self.port, + username=self.username, + password=self._password or None, + client_keys=client_keys, + known_hosts=known_hosts, + agent_path=None, + config=None, + connect_timeout=self.connect_timeout, + keepalive_interval=30, + ) + except asyncssh.HostKeyNotVerifiable as err: + raise SmartctlHostKeyError( + f"{self.host} presented an unexpected host key" + ) from err + except asyncssh.PermissionDenied as err: + raise SmartctlAuthenticationError( + f"Authentication as {self.username} on {self.host} failed" + ) from err + except (OSError, TimeoutError, asyncssh.Error) as err: + raise SmartctlConnectionError( + f"Cannot connect to {self.host}:{self.port}: {err}" + ) from err + + async def run(command: str) -> CommandResult: + try: + result = await asyncio.wait_for( + connection.run(command, check=False), + self.command_timeout, + ) + except TimeoutError as err: + raise SmartctlConnectionError( + f"Command timed out on {self.host}: {command}" + ) from err + except (OSError, asyncssh.Error) as err: + raise SmartctlConnectionError( + f"Lost connection to {self.host}: {err}" + ) from err + return CommandResult( + exit_status=result.exit_status or 0, + stdout=_text(result.stdout), + stderr=_text(result.stderr), + ) + + server_key = connection.get_server_host_key() + try: + yield SmartctlSession( + run, + use_sudo=self.use_sudo, + max_parallel=self.max_parallel, + host_key=( + server_key.export_public_key("openssh").decode().strip() + if server_key + else None + ), + ) + finally: + connection.close() + await connection.wait_closed() + + +class SmartctlSession: + """Runs smartctl commands through a command runner.""" + + def __init__( + self, + runner: CommandRunner, + *, + use_sudo: bool = False, + max_parallel: int = 4, + host_key: str | None = None, + ) -> None: + """Initialize the session.""" + self._runner = runner + self._use_sudo = use_sudo + self._semaphore = asyncio.Semaphore(max_parallel) + self.host_key = host_key + self.smartctl_version: str | None = None + + async def _shell(self, script: str) -> CommandResult: + """Run a POSIX shell script, whatever the user's login shell is.""" + return await self._runner(f"sh -c {shlex.quote(script)}") + + async def _smartctl(self, args: Sequence[str]) -> CommandResult: + argv = ["smartctl", *args] + if self._use_sudo: + argv = ["sudo", "-n", *argv] + script = ( + f'PATH="$PATH:{_EXTRA_PATH}" LC_ALL=C; export PATH LC_ALL; ' + f"exec {shlex.join(argv)}" + ) + async with self._semaphore: + result = await self._shell(script) + _LOGGER.debug( + "%s exited with %s: %s", shlex.join(argv), result.exit_status, result.stderr + ) + stderr = result.stderr.strip() + if self._use_sudo and not result.stdout and "sudo" in stderr: + if result.exit_status in (126, 127): + raise SmartctlPermissionError(f"sudo is not available: {stderr}") + if stderr.startswith("sudo:") and "command not found" not in stderr: + raise SmartctlPermissionError(stderr) + if result.exit_status in (126, 127) or ( + not result.stdout and "command not found" in stderr + ): + raise SmartctlNotFoundError( + "smartctl was not found; install smartmontools on the host" + ) + return result + + async def get_host_info(self) -> HostInfo: + """Return information about the host.""" + result = await self._shell(_HOST_INFO_SCRIPT) + info = parse_host_info(result.stdout) + if self.smartctl_version is None: + await self.check_smartctl() + return HostInfo( + hostname=info.hostname, + os_name=info.os_name, + os_version=info.os_version, + kernel=info.kernel, + smartctl_version=self.smartctl_version, + ) + + async def check_smartctl(self) -> str: + """Make sure smartctl can be run and return its version.""" + result = await self._smartctl(["--version"]) + version = parse_smartctl_version(result.stdout) + if version is None: + raise SmartctlCommandError( + f"Unexpected output from smartctl --version: {result.stdout[:200]}" + ) + self.smartctl_version = version + return version + + @property + def supports_json(self) -> bool: + """Return whether smartctl is new enough for JSON output (7.0).""" + if self.smartctl_version is None: + return True + return int(self.smartctl_version.split(".", 1)[0]) >= 7 + + async def discover(self) -> list[DeviceSpec]: + """Find disks, including disks behind RAID controllers.""" + if self.smartctl_version is None: + await self.check_smartctl() + args = ["--scan-open"] + if self.supports_json: + args.append("--json=c") + result = await self._smartctl(args) + specs = parse_scan(result.stdout) + if specs: + return specs + + _LOGGER.debug("smartctl --scan-open found nothing, falling back to lsblk") + result = await self._shell( + "lsblk -d -J -o NAME,TYPE 2>/dev/null || lsblk -d -n -o NAME,TYPE" + ) + return parse_lsblk(result.stdout) + + async def get_disk(self, spec: DeviceSpec, *, skip_standby: bool = True) -> Disk: + """Read SMART data from one disk. + + With ``skip_standby`` a sleeping disk is not woken up; the result then + has ``standby`` set and carries no SMART values. + """ + if self.smartctl_version is None: + await self.check_smartctl() + args = ["-a"] + if self.supports_json: + args.insert(0, "--json=c") + if skip_standby: + args += ["-n", "standby"] + result = await self._smartctl([*args, *spec.args()]) + disk = parse_smartctl(spec, result.stdout, result.exit_status) + if disk.error and "permission denied" in disk.error.lower(): + disk.error += ( + "; run smartctl as root, enable sudo, or give the user access " + "to the disk devices" + ) + return disk + + async def get_disks( + self, + specs: Sequence[DeviceSpec] | None = None, + *, + skip_standby: bool = True, + ) -> list[Disk]: + """Read SMART data from the given disks, or from all discovered disks.""" + if specs is None: + specs = await self.discover() + disks = await asyncio.gather( + *(self.get_disk(spec, skip_standby=skip_standby) for spec in specs) + ) + return deduplicate(disks) + + async def start_self_test( + self, spec: DeviceSpec, test_type: SelfTestType = SelfTestType.SHORT + ) -> str: + """Start a self-test; smartctl returns as soon as the disk accepted it.""" + result = await self._smartctl(["-t", test_type.value, *spec.args()]) + if result.exit_status & (EXIT_COMMAND_LINE | EXIT_DEVICE_OPEN): + raise SmartctlCommandError( + f"Could not start {test_type} self-test on {spec.key}: " + f"{(result.stdout or result.stderr).strip()[-500:]}" + ) + return result.stdout + + async def abort_self_test(self, spec: DeviceSpec) -> None: + """Abort a running self-test.""" + result = await self._smartctl(["-X", *spec.args()]) + if result.exit_status & (EXIT_COMMAND_LINE | EXIT_DEVICE_OPEN): + raise SmartctlCommandError( + f"Could not abort self-test on {spec.key}: " + f"{(result.stdout or result.stderr).strip()[-500:]}" + ) + + +def _text(value: str | bytes | None) -> str: + if value is None: + return "" + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + return value diff --git a/library/src/aiosmartctl/exceptions.py b/library/src/aiosmartctl/exceptions.py new file mode 100644 index 0000000..c9181d2 --- /dev/null +++ b/library/src/aiosmartctl/exceptions.py @@ -0,0 +1,29 @@ +"""Exceptions for aiosmartctl.""" + + +class SmartctlError(Exception): + """Base exception for aiosmartctl.""" + + +class SmartctlConnectionError(SmartctlError): + """The host could not be reached or the connection dropped.""" + + +class SmartctlAuthenticationError(SmartctlError): + """The host rejected the credentials.""" + + +class SmartctlHostKeyError(SmartctlError): + """The host presented a different SSH host key than the trusted one.""" + + +class SmartctlNotFoundError(SmartctlError): + """smartctl is not installed on the host.""" + + +class SmartctlPermissionError(SmartctlError): + """smartctl, or sudo for it, is not allowed for this user.""" + + +class SmartctlCommandError(SmartctlError): + """A smartctl command failed.""" diff --git a/library/src/aiosmartctl/models.py b/library/src/aiosmartctl/models.py new file mode 100644 index 0000000..728cd08 --- /dev/null +++ b/library/src/aiosmartctl/models.py @@ -0,0 +1,225 @@ +"""Data models for aiosmartctl.""" + +from __future__ import annotations + +import shlex +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Any + + +class DiskType(StrEnum): + """Kind of storage medium.""" + + HDD = "hdd" + SSD = "ssd" + NVME = "nvme" + UNKNOWN = "unknown" + + +class Interface(StrEnum): + """Protocol smartctl used to talk to the disk.""" + + ATA = "ata" + NVME = "nvme" + SCSI = "scsi" + UNKNOWN = "unknown" + + +class HealthStatus(StrEnum): + """Overall health of a disk.""" + + PASSED = "passed" + WARNING = "warning" + FAILED = "failed" + UNKNOWN = "unknown" + + +class SelfTestType(StrEnum): + """Self-tests that can be started with ``smartctl -t``.""" + + SHORT = "short" + LONG = "long" + CONVEYANCE = "conveyance" + + +class SelfTestResult(StrEnum): + """Outcome of a self-test log entry.""" + + PASSED = "passed" + FAILED = "failed" + ABORTED = "aborted" + IN_PROGRESS = "in_progress" + UNKNOWN = "unknown" + + +@dataclass(frozen=True, kw_only=True) +class DeviceSpec: + """A device path plus the optional ``-d`` type smartctl needs to reach it. + + Disks behind RAID controllers and USB bridges are only reachable with a + device type, for example ``/dev/bus/0`` with ``megaraid,3`` or + ``/dev/sg1`` with ``cciss,0``. + """ + + path: str + device_type: str | None = None + + @property + def key(self) -> str: + """Return a string that identifies this spec on its host.""" + if self.device_type: + return f"{self.path}:{self.device_type}" + return self.path + + def args(self) -> list[str]: + """Return the smartctl arguments that select this device.""" + if self.device_type: + return ["-d", self.device_type, self.path] + return [self.path] + + @classmethod + def parse(cls, value: str) -> DeviceSpec: + """Parse ``/dev/sda``, ``/dev/bus/0 megaraid,3`` or ``/dev/sda -d sat``.""" + parts = shlex.split(value) + if not parts: + raise ValueError("Empty device specification") + if not parts[0].startswith("/"): + raise ValueError(f"Device path must be absolute: {parts[0]}") + rest = parts[1:] + if rest and rest[0] == "-d": + rest = rest[1:] + if not rest: + raise ValueError(f"Missing device type after -d: {value}") + if len(rest) > 1: + raise ValueError(f"Unexpected arguments in device specification: {value}") + device_type = rest[0] if rest else None + if device_type is not None and not _is_safe_token(device_type): + raise ValueError(f"Invalid device type: {device_type}") + return cls(path=parts[0], device_type=device_type) + + +def _is_safe_token(value: str) -> bool: + return bool(value) and all(c.isalnum() or c in ",+_-" for c in value) + + +@dataclass(frozen=True, kw_only=True) +class AtaAttribute: + """A row from the ATA SMART attribute table.""" + + id: int + name: str + value: int | None + worst: int | None + threshold: int | None + raw: int | None + prefailure: bool = False + failing_now: bool = False + + +@dataclass(frozen=True, kw_only=True) +class SelfTestEntry: + """A self-test log entry, newest first.""" + + test_type: str + status: str + result: SelfTestResult + lifetime_hours: int | None = None + + +@dataclass(frozen=True, kw_only=True) +class HostInfo: + """Information about the host the disks are attached to.""" + + hostname: str + os_name: str | None = None + os_version: str | None = None + kernel: str | None = None + smartctl_version: str | None = None + + +@dataclass(kw_only=True) +class Disk: + """SMART information about a single disk. + + Values that the disk does not report are ``None``. + """ + + spec: DeviceSpec + model: str | None = None + serial: str | None = None + wwn: str | None = None + vendor: str | None = None + firmware: str | None = None + capacity_bytes: int | None = None + rotation_rate: int | None = None + form_factor: str | None = None + disk_type: DiskType = DiskType.UNKNOWN + interface: Interface = Interface.UNKNOWN + + health: HealthStatus = HealthStatus.UNKNOWN + standby: bool = False + exit_status: int = 0 + messages: list[str] = field(default_factory=list) + error: str | None = None + + temperature: int | None = None + temperature_trip: int | None = None + power_on_hours: int | None = None + power_cycles: int | None = None + + # ATA + reallocated_sectors: int | None = None + reallocation_events: int | None = None + pending_sectors: int | None = None + offline_uncorrectable: int | None = None + reported_uncorrectable: int | None = None + command_timeouts: int | None = None + udma_crc_errors: int | None = None + spin_retry_count: int | None = None + ata_attributes: dict[int, AtaAttribute] = field(default_factory=dict) + + # NVMe + available_spare: int | None = None + available_spare_threshold: int | None = None + media_errors: int | None = None + unsafe_shutdowns: int | None = None + error_log_entries: int | None = None + critical_warning: int | None = None + + # SCSI / SAS + grown_defects: int | None = None + uncorrected_read_errors: int | None = None + uncorrected_write_errors: int | None = None + uncorrected_verify_errors: int | None = None + non_medium_errors: int | None = None + + # Shared + percentage_used: int | None = None + data_read_bytes: int | None = None + data_written_bytes: int | None = None + + self_tests: list[SelfTestEntry] = field(default_factory=list) + self_test_in_progress: bool = False + self_test_remaining_percent: int | None = None + supported_self_tests: frozenset[SelfTestType] = frozenset() + + raw: dict[str, Any] = field(default_factory=dict, repr=False) + + @property + def identifier(self) -> str | None: + """Return an identifier that follows the disk between paths and hosts.""" + if self.wwn: + return f"wwn-{self.wwn}" + if self.serial: + model = (self.model or "disk").strip().replace(" ", "_") + return f"{model}_{self.serial}".lower() + return None + + @property + def last_self_test(self) -> SelfTestEntry | None: + """Return the newest completed self-test log entry.""" + for entry in self.self_tests: + if entry.result is not SelfTestResult.IN_PROGRESS: + return entry + return None diff --git a/library/src/aiosmartctl/parser.py b/library/src/aiosmartctl/parser.py new file mode 100644 index 0000000..25fa4d0 --- /dev/null +++ b/library/src/aiosmartctl/parser.py @@ -0,0 +1,718 @@ +"""Parsers for smartctl, lsblk and host information output.""" + +from __future__ import annotations + +import json +import re +from collections.abc import Iterable +from typing import Any + +from .models import ( + AtaAttribute, + DeviceSpec, + Disk, + DiskType, + HealthStatus, + HostInfo, + Interface, + SelfTestEntry, + SelfTestResult, + SelfTestType, +) + +# smartctl exit status bits, see "man smartctl", section RETURN VALUES. +EXIT_COMMAND_LINE = 1 << 0 +EXIT_DEVICE_OPEN = 1 << 1 +EXIT_SMART_COMMAND = 1 << 2 +EXIT_DISK_FAILING = 1 << 3 +EXIT_PREFAIL_NOW = 1 << 4 + +NVME_DATA_UNIT_BYTES = 512_000 + +_STANDBY_RE = re.compile(r"\bin (STANDBY|SLEEP)\b.*\bmode\b", re.IGNORECASE) + +# ATA attributes whose normalized value counts down from 100 as an SSD wears. +_ATA_LIFE_LEFT_ATTRIBUTES = ( + "Percent_Lifetime_Remain", + "SSD_Life_Left", + "Media_Wearout_Indicator", + "Remaining_Lifetime_Perc", + "Wear_Leveling_Count", +) + +_VENDORS = { + "western digital": "Western Digital", + "wdc": "Western Digital", + "seagate": "Seagate", + "samsung": "Samsung", + "toshiba": "Toshiba", + "kioxia": "Kioxia", + "hgst": "HGST", + "hitachi": "Hitachi", + "intel": "Intel", + "solidigm": "Solidigm", + "micron": "Micron", + "crucial": "Crucial", + "kingston": "Kingston", + "sandisk": "SanDisk", + "sk hynix": "SK hynix", + "hynix": "SK hynix", + "dell": "Dell", + "hpe": "HPE", + "hp": "HP", + "lenovo": "Lenovo", + "ibm": "IBM", + "netapp": "NetApp", + "corsair": "Corsair", + "adata": "ADATA", + "phison": "Phison", +} + +_MODEL_NUMBER_VENDORS = ( + (re.compile(r"ST\d", re.IGNORECASE), "Seagate"), + (re.compile(r"WD[A-Z]?\d", re.IGNORECASE), "Western Digital"), + (re.compile(r"CT\d", re.IGNORECASE), "Crucial"), + (re.compile(r"MZ[0-9A-Z]{2}", re.IGNORECASE), "Samsung"), +) + + +def parse_scan(output: str) -> list[DeviceSpec]: + """Parse ``smartctl --scan-open`` output in JSON or text form.""" + stripped = output.strip() + if stripped.startswith("{"): + data = json.loads(stripped) + specs = [] + for device in data.get("devices", []): + if device.get("open_error") or not device.get("name"): + continue + specs.append( + DeviceSpec(path=device["name"], device_type=device.get("type")) + ) + return _unique(specs) + + specs = [] + for line in stripped.splitlines(): + line = line.split("#", 1)[0].strip() + if not line: + continue + try: + specs.append(DeviceSpec.parse(line)) + except ValueError: + continue + return _unique(specs) + + +def parse_lsblk(output: str) -> list[DeviceSpec]: + """Parse ``lsblk -d -J -o NAME,TYPE`` or ``lsblk -d -n -o NAME,TYPE``.""" + stripped = output.strip() + names: list[str] = [] + if stripped.startswith("{"): + for device in json.loads(stripped).get("blockdevices", []): + if device.get("type") == "disk": + names.append(device.get("name", "")) + else: + for line in stripped.splitlines(): + parts = line.split() + if len(parts) >= 2 and parts[1] == "disk": + names.append(parts[0]) + return _unique( + DeviceSpec(path=f"/dev/{name}") + for name in names + if name and not name.startswith(("loop", "zram", "ram")) + ) + + +def parse_host_info(output: str) -> HostInfo: + """Parse the output of the host information script. + + The script prints ``key=value`` lines; ``/etc/os-release`` lines are + passed through as-is. + """ + values: dict[str, str] = {} + for line in output.splitlines(): + key, sep, value = line.partition("=") + if sep: + values.setdefault(key.strip(), value.strip().strip('"')) + + os_name = values.get("NAME") + os_version = values.get("VERSION_ID") or values.get("VERSION") + if unraid := values.get("UNRAID_VERSION"): + os_name, os_version = "Unraid", unraid + elif pve := values.get("PVE_VERSION"): + os_name, os_version = "Proxmox VE", pve + if not os_name: + os_name = values.get("UNAME_S") + + return HostInfo( + hostname=values.get("HOSTNAME", ""), + os_name=os_name or None, + os_version=os_version or None, + kernel=values.get("UNAME_R") or None, + smartctl_version=values.get("SMARTCTL") or None, + ) + + +def parse_smartctl_version(output: str) -> str | None: + """Return the version from the first line of ``smartctl --version``.""" + match = re.search(r"smartctl\s+(\d+\.\d+(?:\.\d+)?)", output) + return match.group(1) if match else None + + +def parse_smartctl(spec: DeviceSpec, output: str, exit_status: int) -> Disk: + """Parse ``smartctl -a`` output, preferring JSON.""" + stripped = output.strip() + if stripped.startswith("{"): + try: + data = json.loads(stripped) + except ValueError: + pass + else: + return parse_smartctl_json(spec, data) + return parse_smartctl_text(spec, output, exit_status) + + +def parse_smartctl_json(spec: DeviceSpec, data: dict[str, Any]) -> Disk: + """Parse ``smartctl --json -a`` output.""" + smartctl = data.get("smartctl", {}) + disk = Disk( + spec=spec, + raw=data, + exit_status=_int(smartctl.get("exit_status")) or 0, + messages=[ + str(message.get("string", "")) + for message in smartctl.get("messages", []) + if message.get("string") + ], + ) + + if any(_STANDBY_RE.search(message) for message in disk.messages): + disk.standby = True + return disk + if disk.exit_status & (EXIT_COMMAND_LINE | EXIT_DEVICE_OPEN): + disk.error = _error_message(disk.messages) or ( + f"smartctl exited with status {disk.exit_status}" + ) + return disk + + protocol = str(data.get("device", {}).get("protocol", "")).upper() + disk.interface = { + "ATA": Interface.ATA, + "NVME": Interface.NVME, + "SCSI": Interface.SCSI, + }.get(protocol, Interface.UNKNOWN) + + disk.model = _str(data.get("model_name")) or _str( + " ".join(filter(None, (data.get("vendor"), data.get("product")))) + ) + disk.serial = _str(data.get("serial_number")) + disk.firmware = _str(data.get("firmware_version")) or _str(data.get("revision")) + disk.wwn = _wwn(data) + disk.vendor = _vendor( + _str(data.get("vendor")), _str(data.get("model_family")), disk.model + ) + disk.capacity_bytes = _int(data.get("user_capacity", {}).get("bytes")) or _int( + data.get("nvme_total_capacity") + ) + disk.rotation_rate = _int(data.get("rotation_rate")) + disk.form_factor = _str(data.get("form_factor", {}).get("name")) + + if disk.interface is Interface.NVME: + disk.disk_type = DiskType.NVME + elif disk.rotation_rate == 0: + disk.disk_type = DiskType.SSD + elif disk.rotation_rate: + disk.disk_type = DiskType.HDD + + temperature = data.get("temperature", {}) + disk.temperature = _int(temperature.get("current")) + disk.temperature_trip = _int(temperature.get("drive_trip")) + disk.power_on_hours = _int(data.get("power_on_time", {}).get("hours")) + disk.power_cycles = _int(data.get("power_cycle_count")) + + _apply_ata(disk, data) + _apply_nvme(disk, data) + _apply_scsi(disk, data) + disk.health = _health(disk, data) + return disk + + +def parse_smartctl_text(spec: DeviceSpec, output: str, exit_status: int) -> Disk: + """Parse plain ``smartctl -a`` output from smartctl versions without JSON.""" + disk = Disk(spec=spec, exit_status=exit_status) + lines = output.splitlines() + + if _STANDBY_RE.search(output): + disk.standby = True + return disk + if exit_status & (EXIT_COMMAND_LINE | EXIT_DEVICE_OPEN): + disk.error = _error_message(lines) or ( + f"smartctl exited with status {exit_status}" + ) + return disk + + vendor = product = None + for line in lines: + key, sep, value = line.partition(":") + if not sep: + continue + key = key.strip().lower() + value = value.strip() + if key in ("device model", "model number"): + disk.model = value + elif key == "model family": + disk.vendor = _vendor(None, value, None) + elif key == "vendor": + vendor = value + elif key == "product": + product = value + elif key == "serial number": + disk.serial = value + elif key in ("firmware version", "revision"): + disk.firmware = value + elif key in ("user capacity", "total nvm capacity"): + if match := re.match(r"([\d,.\s]+) bytes", value): + disk.capacity_bytes = int(re.sub(r"\D", "", match.group(1))) + elif key == "rotation rate": + if "solid state" in value.lower(): + disk.rotation_rate = 0 + elif match := re.match(r"(\d+)", value): + disk.rotation_rate = int(match.group(1)) + elif key in ("current drive temperature", "temperature"): + if match := re.match(r"(\d+)", value): + disk.temperature = int(match.group(1)) + elif key == "elements in grown defect list": + disk.grown_defects = _int(value) + elif "overall-health" in key or key == "smart health status": + upper = value.upper() + if "PASSED" in upper or upper == "OK": + disk.health = HealthStatus.PASSED + elif "FAIL" in upper: + disk.health = HealthStatus.FAILED + + if vendor or product: + disk.model = disk.model or " ".join(filter(None, (vendor, product))) + disk.interface = Interface.SCSI + disk.vendor = disk.vendor or _vendor(vendor, None, disk.model) + if "NVMe" in output and "Model Number" in output: + disk.interface = Interface.NVME + disk.disk_type = DiskType.NVME + elif disk.rotation_rate == 0: + disk.disk_type = DiskType.SSD + elif disk.rotation_rate: + disk.disk_type = DiskType.HDD + + attributes = list(_parse_ata_attribute_table(lines)) + if attributes: + disk.interface = Interface.ATA + _apply_ata_attributes(disk, attributes) + if disk.health is HealthStatus.PASSED and any( + attribute.failing_now for attribute in attributes + ): + disk.health = HealthStatus.WARNING + if exit_status & EXIT_DISK_FAILING: + disk.health = HealthStatus.FAILED + return disk + + +def deduplicate(disks: Iterable[Disk]) -> list[Disk]: + """Drop disks seen twice, such as SAS disks reachable over two paths.""" + seen: set[str] = set() + result = [] + for disk in disks: + identifier = disk.identifier + if identifier is not None: + if identifier in seen: + continue + seen.add(identifier) + result.append(disk) + return result + + +# --------------------------------------------------------------------------- +# ATA +# --------------------------------------------------------------------------- + + +def _apply_ata(disk: Disk, data: dict[str, Any]) -> None: + table = data.get("ata_smart_attributes", {}).get("table", []) + attributes = [ + AtaAttribute( + id=_int(row.get("id")) or 0, + name=str(row.get("name", "")), + value=_int(row.get("value")), + worst=_int(row.get("worst")), + threshold=_int(row.get("thresh")), + raw=_int(row.get("raw", {}).get("value")), + prefailure=bool(row.get("flags", {}).get("prefailure")), + failing_now=row.get("when_failed") == "now", + ) + for row in table + ] + _apply_ata_attributes( + disk, attributes, logical_block_size=_int(data.get("logical_block_size")) + ) + + smart_data = data.get("ata_smart_data", {}) + capabilities = smart_data.get("capabilities", {}) + if capabilities.get("self_tests_supported"): + tests = {SelfTestType.SHORT, SelfTestType.LONG} + if capabilities.get("conveyance_self_test_supported"): + tests.add(SelfTestType.CONVEYANCE) + disk.supported_self_tests = frozenset(tests) + + status = smart_data.get("self_test", {}).get("status", {}) + if (_int(status.get("value")) or 0) >> 4 == 0xF: + disk.self_test_in_progress = True + disk.self_test_remaining_percent = _int(status.get("remaining_percent")) + + log = data.get("ata_smart_self_test_log", {}).get("standard", {}) + disk.self_tests = [ + SelfTestEntry( + test_type=str(row.get("type", {}).get("string", "")), + status=str(row.get("status", {}).get("string", "")), + result=_ata_self_test_result(row.get("status", {})), + lifetime_hours=_int(row.get("lifetime_hours")), + ) + for row in log.get("table", []) + ] + + +def _apply_ata_attributes( + disk: Disk, + attributes: list[AtaAttribute], + *, + logical_block_size: int | None = None, +) -> None: + by_name: dict[str, AtaAttribute] = {} + for attribute in attributes: + disk.ata_attributes[attribute.id] = attribute + by_name[attribute.name] = attribute + raw = attribute.raw + match attribute.id: + case 5: + disk.reallocated_sectors = raw + case 9 if disk.power_on_hours is None: + disk.power_on_hours = raw + case 10: + disk.spin_retry_count = raw + case 12 if disk.power_cycles is None: + disk.power_cycles = raw + case 187: + disk.reported_uncorrectable = raw + case 188: + disk.command_timeouts = raw + case 194 | 190 if disk.temperature is None and raw is not None: + # The upper bytes of the raw value often hold min/max values. + disk.temperature = raw & 0xFF + case 196: + disk.reallocation_events = raw + case 197: + disk.pending_sectors = raw + case 198: + disk.offline_uncorrectable = raw + case 199: + disk.udma_crc_errors = raw + + block_size = logical_block_size or 512 + if (written := by_name.get("Total_LBAs_Written")) and written.raw is not None: + disk.data_written_bytes = written.raw * block_size + if (read := by_name.get("Total_LBAs_Read")) and read.raw is not None: + disk.data_read_bytes = read.raw * block_size + + if disk.disk_type is DiskType.SSD or ( + disk.disk_type is DiskType.UNKNOWN and disk.rotation_rate == 0 + ): + for name in _ATA_LIFE_LEFT_ATTRIBUTES: + life_left = by_name.get(name) + if life_left and life_left.value is not None and life_left.value <= 100: + disk.percentage_used = 100 - life_left.value + break + + +_ATA_ROW_RE = re.compile( + r"^\s*(?P\d+)\s+(?P\S+)\s+0x[0-9a-fA-F]+\s+(?P\d+)\s+" + r"(?P\d+)\s+(?P\d+)\s+(?P\S+)\s+\S+\s+" + r"(?P\S+)\s+(?P\d+)" +) + + +def _parse_ata_attribute_table(lines: list[str]) -> Iterable[AtaAttribute]: + for line in lines: + if match := _ATA_ROW_RE.match(line): + yield AtaAttribute( + id=int(match["id"]), + name=match["name"], + value=int(match["value"]), + worst=int(match["worst"]), + threshold=int(match["thresh"]), + raw=int(match["raw"]), + prefailure=match["type"].lower().startswith("pre"), + failing_now=match["when_failed"].upper() == "FAILING_NOW", + ) + + +def _ata_self_test_result(status: dict[str, Any]) -> SelfTestResult: + value = _int(status.get("value")) + if value is not None: + code = value >> 4 + if code == 0: + return SelfTestResult.PASSED + if code in (1, 2): + return SelfTestResult.ABORTED + if code == 0xF: + return SelfTestResult.IN_PROGRESS + if 3 <= code <= 8: + return SelfTestResult.FAILED + if status.get("passed") is True: + return SelfTestResult.PASSED + if status.get("passed") is False: + return SelfTestResult.FAILED + return _result_from_text(str(status.get("string", ""))) + + +# --------------------------------------------------------------------------- +# NVMe +# --------------------------------------------------------------------------- + + +def _apply_nvme(disk: Disk, data: dict[str, Any]) -> None: + log = data.get("nvme_smart_health_information_log") + if not log: + return + disk.disk_type = DiskType.NVME + disk.interface = Interface.NVME + disk.critical_warning = _int(log.get("critical_warning")) + if disk.temperature is None: + disk.temperature = _int(log.get("temperature")) + disk.available_spare = _int(log.get("available_spare")) + disk.available_spare_threshold = _int(log.get("available_spare_threshold")) + disk.percentage_used = _int(log.get("percentage_used")) + disk.media_errors = _int(log.get("media_errors")) + disk.unsafe_shutdowns = _int(log.get("unsafe_shutdowns")) + disk.error_log_entries = _int(log.get("num_err_log_entries")) + if disk.power_on_hours is None: + disk.power_on_hours = _int(log.get("power_on_hours")) + if disk.power_cycles is None: + disk.power_cycles = _int(log.get("power_cycles")) + if (units := _int(log.get("data_units_read"))) is not None: + disk.data_read_bytes = units * NVME_DATA_UNIT_BYTES + if (units := _int(log.get("data_units_written"))) is not None: + disk.data_written_bytes = units * NVME_DATA_UNIT_BYTES + + self_test_log = data.get("nvme_self_test_log") + if self_test_log is None: + return + disk.supported_self_tests = frozenset({SelfTestType.SHORT, SelfTestType.LONG}) + if _int(self_test_log.get("current_self_test_operation", {}).get("value")): + disk.self_test_in_progress = True + completion = _int(self_test_log.get("current_self_test_completion_percent")) + if completion is not None: + disk.self_test_remaining_percent = 100 - completion + disk.self_tests = [ + SelfTestEntry( + test_type=str(row.get("self_test_code", {}).get("string", "")), + status=str(row.get("self_test_result", {}).get("string", "")), + result=_nvme_self_test_result(row.get("self_test_result", {})), + lifetime_hours=_int(row.get("power_on_hours")), + ) + for row in self_test_log.get("table", []) + ] + + +def _nvme_self_test_result(result: dict[str, Any]) -> SelfTestResult: + match _int(result.get("value")): + case 0: + return SelfTestResult.PASSED + case 1 | 2 | 3 | 4 | 8 | 9: + return SelfTestResult.ABORTED + case 5 | 6 | 7: + return SelfTestResult.FAILED + return _result_from_text(str(result.get("string", ""))) + + +# --------------------------------------------------------------------------- +# SCSI / SAS +# --------------------------------------------------------------------------- + + +def _apply_scsi(disk: Disk, data: dict[str, Any]) -> None: + if disk.interface is not Interface.SCSI: + return + disk.grown_defects = _int(data.get("scsi_grown_defect_list")) + if (used := _int(data.get("scsi_percentage_used_endurance_indicator"))) is not None: + disk.percentage_used = used + disk.non_medium_errors = _int( + data.get("scsi_nonmedium_error_count", data.get("scsi_non_medium_error_count")) + ) + + counters = data.get("scsi_error_counter_log", {}) + disk.uncorrected_read_errors = _int( + counters.get("read", {}).get("total_uncorrected_errors") + ) + disk.uncorrected_write_errors = _int( + counters.get("write", {}).get("total_uncorrected_errors") + ) + disk.uncorrected_verify_errors = _int( + counters.get("verify", {}).get("total_uncorrected_errors") + ) + if ( + gigabytes := _float(counters.get("read", {}).get("gigabytes_processed")) + ) is not None: + disk.data_read_bytes = round(gigabytes * 1_000_000_000) + if ( + gigabytes := _float(counters.get("write", {}).get("gigabytes_processed")) + ) is not None: + disk.data_written_bytes = round(gigabytes * 1_000_000_000) + + cycles = data.get("scsi_start_stop_cycle_counter", {}) + if disk.power_cycles is None: + disk.power_cycles = _int(cycles.get("accumulated_start_stop_cycles")) + + disk.supported_self_tests = frozenset({SelfTestType.SHORT, SelfTestType.LONG}) + entries = [] + for index in range(20): + row = data.get(f"scsi_self_test_{index}") + if row is None: + break + result = row.get("result", {}) + entries.append( + SelfTestEntry( + test_type=str(row.get("code", {}).get("string", "")), + status=str(result.get("string", "")), + result=_scsi_self_test_result(result), + lifetime_hours=_int(row.get("power_on_time", {}).get("hours")), + ) + ) + disk.self_tests = entries + if entries and entries[0].result is SelfTestResult.IN_PROGRESS: + disk.self_test_in_progress = True + + +def _scsi_self_test_result(result: dict[str, Any]) -> SelfTestResult: + match _int(result.get("value")): + case 0: + return SelfTestResult.PASSED + case 1 | 2: + return SelfTestResult.ABORTED + case 3 | 4 | 5 | 6 | 7: + return SelfTestResult.FAILED + case 15: + return SelfTestResult.IN_PROGRESS + return _result_from_text(str(result.get("string", ""))) + + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + + +def _health(disk: Disk, data: dict[str, Any]) -> HealthStatus: + passed = data.get("smart_status", {}).get("passed") + if passed is False or disk.exit_status & EXIT_DISK_FAILING: + return HealthStatus.FAILED + warning = ( + disk.exit_status & EXIT_PREFAIL_NOW + or any(attribute.failing_now for attribute in disk.ata_attributes.values()) + or bool(disk.critical_warning) + or ( + disk.available_spare is not None + and disk.available_spare_threshold is not None + and disk.available_spare < disk.available_spare_threshold + ) + ) + if warning: + return HealthStatus.WARNING + if passed is True: + return HealthStatus.PASSED + return HealthStatus.UNKNOWN + + +def _result_from_text(text: str) -> SelfTestResult: + lower = text.lower() + if "progress" in lower: + return SelfTestResult.IN_PROGRESS + if "without error" in lower or lower == "completed": + return SelfTestResult.PASSED + if "abort" in lower or "interrupt" in lower: + return SelfTestResult.ABORTED + if "fail" in lower or "error" in lower: + return SelfTestResult.FAILED + return SelfTestResult.UNKNOWN + + +def _error_message(messages: Iterable[str]) -> str | None: + for message in messages: + if "failed" in message.lower() or "denied" in message.lower(): + return message.strip() + return None + + +def _wwn(data: dict[str, Any]) -> str | None: + wwn = data.get("wwn") + if isinstance(wwn, dict) and {"naa", "oui", "id"} <= wwn.keys(): + return f"0x{wwn['naa']:x}{wwn['oui']:06x}{wwn['id']:09x}" + if lu_id := _str(data.get("logical_unit_id")): + return lu_id.lower() + return None + + +def _vendor( + scsi_vendor: str | None, family: str | None, model: str | None +) -> str | None: + for candidate in (scsi_vendor, family, model): + if not candidate: + continue + lower = candidate.lower() + for prefix, name in _VENDORS.items(): + if lower == prefix or lower.startswith( + (f"{prefix} ", f"{prefix}-", f"{prefix}_") + ): + return name + if model: + # Bare model numbers such as "ST4000VN008", "WD40EFRX" or "CT1000MX500SSD1". + for pattern, name in _MODEL_NUMBER_VENDORS: + if pattern.match(model): + return name + return scsi_vendor + + +def _unique(specs: Iterable[DeviceSpec]) -> list[DeviceSpec]: + seen: set[str] = set() + result = [] + for spec in specs: + if spec.key not in seen: + seen.add(spec.key) + result.append(spec) + return result + + +def _int(value: Any) -> int | None: + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + if isinstance(value, str): + digits = value.strip().replace(",", "") + if digits.isdigit(): + return int(digits) + return None + + +def _float(value: Any) -> float | None: + if isinstance(value, (int, float)) and not isinstance(value, bool): + return float(value) + if isinstance(value, str): + try: + return float(value.replace(",", "")) + except ValueError: + return None + return None + + +def _str(value: Any) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None diff --git a/library/src/aiosmartctl/py.typed b/library/src/aiosmartctl/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/library/tests/__init__.py b/library/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/library/tests/conftest.py b/library/tests/conftest.py new file mode 100644 index 0000000..1b27e22 --- /dev/null +++ b/library/tests/conftest.py @@ -0,0 +1,92 @@ +"""Fixtures for aiosmartctl tests.""" + +from __future__ import annotations + +import json +import shlex +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import pytest + +from aiosmartctl import CommandResult + +FIXTURES = Path(__file__).parent / "fixtures" + + +def load_fixture(name: str) -> str: + """Return the contents of a fixture file.""" + return (FIXTURES / name).read_text() + + +def load_json(name: str) -> dict[str, Any]: + """Return a JSON fixture.""" + return json.loads(load_fixture(name)) + + +class FakeHost: + """Answers commands like a host with smartctl installed would.""" + + def __init__(self) -> None: + """Initialize the fake host.""" + self.commands: list[list[str]] = [] + self.responses: list[tuple[Callable[[list[str]], bool], CommandResult]] = [] + + def respond( + self, + matcher: Callable[[list[str]], bool], + stdout: str = "", + *, + exit_status: int = 0, + stderr: str = "", + ) -> None: + """Answer commands that match with the given output.""" + self.responses.insert( + 0, + ( + matcher, + CommandResult(exit_status=exit_status, stdout=stdout, stderr=stderr), + ), + ) + + async def __call__(self, command: str) -> CommandResult: + """Run a command.""" + argv = _argv(command) + self.commands.append(argv) + for matcher, result in self.responses: + if matcher(argv): + return result + return CommandResult(exit_status=127, stdout="", stderr="not found") + + +def _argv(command: str) -> list[str]: + """Return the argv of the program a ``sh -c`` command execs.""" + outer = shlex.split(command) + assert outer[:2] == ["sh", "-c"] + script = outer[2] + if "exec " in script: + return shlex.split(script.split("exec ", 1)[1]) + return ["