smartscan 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- smartscan/__init__.py +6 -0
- smartscan/__main__.py +4 -0
- smartscan/cli.py +139 -0
- smartscan/commands.py +144 -0
- smartscan/config.py +54 -0
- smartscan/constants.py +86 -0
- smartscan/database.py +183 -0
- smartscan/exceptions.py +25 -0
- smartscan/llm.py +105 -0
- smartscan/logging.py +43 -0
- smartscan/models.py +71 -0
- smartscan/output.py +204 -0
- smartscan/smartctl.py +284 -0
- smartscan/thresholds.py +109 -0
- smartscan-0.1.0.dist-info/METADATA +55 -0
- smartscan-0.1.0.dist-info/RECORD +19 -0
- smartscan-0.1.0.dist-info/WHEEL +4 -0
- smartscan-0.1.0.dist-info/entry_points.txt +2 -0
- smartscan-0.1.0.dist-info/licenses/LICENSE +19 -0
smartscan/__init__.py
ADDED
smartscan/__main__.py
ADDED
smartscan/cli.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# PYTHON_ARGCOMPLETE_OK
|
|
2
|
+
|
|
3
|
+
"""CLI entry point with argparse subcommands for collect and query modes."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import logging
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
import argcomplete
|
|
12
|
+
|
|
13
|
+
from .commands import do_collect, do_query
|
|
14
|
+
from .config import load_config
|
|
15
|
+
from .constants import DEFAULT_CONFIG_PATH, DEFAULT_DB_PATH, DEFAULT_LOG_FILE
|
|
16
|
+
from .exceptions import SmartScanError
|
|
17
|
+
from .logging import setup_logging
|
|
18
|
+
from .models import SmartScanConfig
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def create_parser() -> argparse.ArgumentParser:
|
|
22
|
+
"""Build the argument parser for argcomplete integration, without config defaults."""
|
|
23
|
+
return _build_parser()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _build_parser(config: SmartScanConfig | None = None) -> argparse.ArgumentParser:
|
|
27
|
+
"""Build the argument parser with config-driven defaults."""
|
|
28
|
+
cfg = config or SmartScanConfig()
|
|
29
|
+
|
|
30
|
+
parser = argparse.ArgumentParser(
|
|
31
|
+
description="Extract and display important SMART information from disk devices.",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--config",
|
|
36
|
+
default=DEFAULT_CONFIG_PATH,
|
|
37
|
+
help=f"TOML config file path (default: {DEFAULT_CONFIG_PATH})",
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"--db-path",
|
|
41
|
+
default=cfg.db_path,
|
|
42
|
+
help=f"Database file path (default: {DEFAULT_DB_PATH})",
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"--log-file",
|
|
46
|
+
default=cfg.log_file,
|
|
47
|
+
help=f"Log file path (default: {DEFAULT_LOG_FILE})",
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"--json",
|
|
51
|
+
action="store_true",
|
|
52
|
+
default=cfg.format == "json",
|
|
53
|
+
help="Output as JSON lines instead of table format",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
57
|
+
|
|
58
|
+
collect_parser = sub.add_parser(
|
|
59
|
+
"collect", help="Collect SMART data from disk devices"
|
|
60
|
+
)
|
|
61
|
+
collect_parser.add_argument(
|
|
62
|
+
"pattern",
|
|
63
|
+
nargs="?",
|
|
64
|
+
default=".*",
|
|
65
|
+
help="Regex pattern to filter disk devices",
|
|
66
|
+
)
|
|
67
|
+
collect_parser.add_argument(
|
|
68
|
+
"--no-save",
|
|
69
|
+
action="store_true",
|
|
70
|
+
default=cfg.no_save,
|
|
71
|
+
help="Skip saving SMART data to database",
|
|
72
|
+
)
|
|
73
|
+
collect_parser.add_argument(
|
|
74
|
+
"--force-llm",
|
|
75
|
+
action="store_true",
|
|
76
|
+
help="Force LLM analysis on all disks, bypassing both config and threshold checks",
|
|
77
|
+
)
|
|
78
|
+
collect_parser.add_argument(
|
|
79
|
+
"-v",
|
|
80
|
+
"--verbose",
|
|
81
|
+
action="store_true",
|
|
82
|
+
help="Show extended fields in terminal output",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
query_parser = sub.add_parser(
|
|
86
|
+
"query", help="Query historical SMART data from database"
|
|
87
|
+
)
|
|
88
|
+
query_parser.add_argument(
|
|
89
|
+
"pattern",
|
|
90
|
+
nargs="?",
|
|
91
|
+
default=".*",
|
|
92
|
+
help="Regex pattern to filter disk_name",
|
|
93
|
+
)
|
|
94
|
+
query_parser.add_argument(
|
|
95
|
+
"--since",
|
|
96
|
+
metavar="DATE",
|
|
97
|
+
help="Start date (YYYY-MM-DD) for query",
|
|
98
|
+
)
|
|
99
|
+
query_parser.add_argument(
|
|
100
|
+
"--until",
|
|
101
|
+
metavar="DATE",
|
|
102
|
+
help="End date (YYYY-MM-DD) for query",
|
|
103
|
+
)
|
|
104
|
+
query_parser.add_argument(
|
|
105
|
+
"--verbose",
|
|
106
|
+
"-v",
|
|
107
|
+
action="store_true",
|
|
108
|
+
help="Show extended fields in terminal output",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
argcomplete.autocomplete(parser)
|
|
112
|
+
return parser
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def main() -> None:
|
|
116
|
+
"""Entry point: load config, parse args, and dispatch to the appropriate subcommand."""
|
|
117
|
+
try:
|
|
118
|
+
pre_parser = argparse.ArgumentParser(add_help=False)
|
|
119
|
+
pre_parser.add_argument("--config", default=DEFAULT_CONFIG_PATH)
|
|
120
|
+
pre_args, remaining = pre_parser.parse_known_args()
|
|
121
|
+
|
|
122
|
+
config = load_config(pre_args.config)
|
|
123
|
+
|
|
124
|
+
parser = _build_parser(config)
|
|
125
|
+
args = parser.parse_args(remaining)
|
|
126
|
+
|
|
127
|
+
setup_logging(args.log_file)
|
|
128
|
+
|
|
129
|
+
args.thresholds_enabled = config.thresholds.enabled
|
|
130
|
+
args.threshold_rules = config.thresholds
|
|
131
|
+
args.llm_config = config.llm
|
|
132
|
+
|
|
133
|
+
if args.command == "query":
|
|
134
|
+
do_query(args)
|
|
135
|
+
else:
|
|
136
|
+
do_collect(args)
|
|
137
|
+
except SmartScanError as exc:
|
|
138
|
+
logging.error("%s", exc)
|
|
139
|
+
sys.exit(1)
|
smartscan/commands.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""High-level command implementations for collect and query workflows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import sqlite3
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from argparse import Namespace
|
|
11
|
+
|
|
12
|
+
from .database import init_db, open_db, query_smart_info, save_to_db
|
|
13
|
+
from .exceptions import DiskNotFoundError
|
|
14
|
+
from .llm import call_llm
|
|
15
|
+
from .output import (
|
|
16
|
+
print_json_output,
|
|
17
|
+
print_llm_analysis,
|
|
18
|
+
print_query_table,
|
|
19
|
+
print_table,
|
|
20
|
+
row_to_fields,
|
|
21
|
+
)
|
|
22
|
+
from .smartctl import extract_fields, find_disks, run_smartctl
|
|
23
|
+
from .thresholds import check_thresholds
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def do_collect(args: Namespace) -> None:
|
|
27
|
+
if os.geteuid() != 0:
|
|
28
|
+
logging.warning(
|
|
29
|
+
"This program typically requires root privileges to access SMART data."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
conn = None
|
|
33
|
+
if not args.no_save:
|
|
34
|
+
try:
|
|
35
|
+
conn = init_db(args.db_path)
|
|
36
|
+
except OSError as exc:
|
|
37
|
+
logging.error("Failed to open database at %s: %s", args.db_path, exc)
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
disks = find_disks(args.pattern)
|
|
41
|
+
except DiskNotFoundError as exc:
|
|
42
|
+
logging.error("%s", exc)
|
|
43
|
+
sys.exit(1)
|
|
44
|
+
|
|
45
|
+
thresholds_enabled = args.thresholds_enabled
|
|
46
|
+
|
|
47
|
+
exit_code = 0
|
|
48
|
+
for disk_idx, symlink in enumerate(disks):
|
|
49
|
+
disk_name = symlink.name
|
|
50
|
+
disk_path = os.readlink(symlink)
|
|
51
|
+
|
|
52
|
+
data, rc = run_smartctl(symlink)
|
|
53
|
+
if rc != 0:
|
|
54
|
+
exit_code = 1
|
|
55
|
+
|
|
56
|
+
if not data:
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
fields = extract_fields(data)
|
|
60
|
+
|
|
61
|
+
alerts = []
|
|
62
|
+
if thresholds_enabled:
|
|
63
|
+
alerts = check_thresholds(fields, args.threshold_rules)
|
|
64
|
+
|
|
65
|
+
if args.json:
|
|
66
|
+
print_json_output(disk_name, disk_path, fields, data)
|
|
67
|
+
else:
|
|
68
|
+
print_table(disk_name, fields, alerts, verbose=args.verbose)
|
|
69
|
+
|
|
70
|
+
llm_analysis = None
|
|
71
|
+
if args.force_llm or (args.llm_config.enabled and alerts):
|
|
72
|
+
alerts_text = (
|
|
73
|
+
"\n".join(f" - {a.message}" for a in alerts) if alerts else " (none)"
|
|
74
|
+
)
|
|
75
|
+
llm_analysis = call_llm(fields, alerts_text, args.llm_config, raw_data=data)
|
|
76
|
+
if llm_analysis:
|
|
77
|
+
if not args.json:
|
|
78
|
+
print_llm_analysis(llm_analysis)
|
|
79
|
+
if args.llm_config.delay > 0 and disk_idx < len(disks) - 1:
|
|
80
|
+
time.sleep(args.llm_config.delay)
|
|
81
|
+
|
|
82
|
+
if conn:
|
|
83
|
+
try:
|
|
84
|
+
save_to_db(
|
|
85
|
+
conn, disk_name, disk_path, fields, data, llm_analysis=llm_analysis
|
|
86
|
+
)
|
|
87
|
+
except sqlite3.Error as exc:
|
|
88
|
+
logging.error("Failed to save SMART data for %s: %s", disk_name, exc)
|
|
89
|
+
|
|
90
|
+
if conn:
|
|
91
|
+
conn.close()
|
|
92
|
+
|
|
93
|
+
sys.exit(exit_code)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def do_query(args: Namespace) -> None:
|
|
97
|
+
from .database import parse_date
|
|
98
|
+
|
|
99
|
+
since = parse_date(args.since) if args.since else None
|
|
100
|
+
until = parse_date(args.until) if args.until else None
|
|
101
|
+
|
|
102
|
+
conn = open_db(args.db_path)
|
|
103
|
+
if conn is None:
|
|
104
|
+
sys.exit(0)
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
rows = query_smart_info(conn, args.pattern, since, until)
|
|
108
|
+
except sqlite3.OperationalError as exc:
|
|
109
|
+
logging.error("Query failed: %s", exc)
|
|
110
|
+
conn.close()
|
|
111
|
+
sys.exit(1)
|
|
112
|
+
|
|
113
|
+
if not rows:
|
|
114
|
+
logging.info("No SMART records found matching the query.")
|
|
115
|
+
conn.close()
|
|
116
|
+
sys.exit(0)
|
|
117
|
+
|
|
118
|
+
for row in rows:
|
|
119
|
+
fields = row_to_fields(row)
|
|
120
|
+
|
|
121
|
+
alerts = []
|
|
122
|
+
if args.thresholds_enabled:
|
|
123
|
+
alerts = check_thresholds(fields, args.threshold_rules)
|
|
124
|
+
|
|
125
|
+
if args.json:
|
|
126
|
+
llm_col = row["llm_analysis"] if "llm_analysis" in row.keys() else None
|
|
127
|
+
print_json_output(
|
|
128
|
+
row["disk_name"],
|
|
129
|
+
row["disk_path"],
|
|
130
|
+
fields,
|
|
131
|
+
timestamp=row["timestamp"],
|
|
132
|
+
llm_analysis=llm_col,
|
|
133
|
+
)
|
|
134
|
+
else:
|
|
135
|
+
print_query_table(
|
|
136
|
+
row["disk_name"],
|
|
137
|
+
row["disk_path"],
|
|
138
|
+
row["timestamp"],
|
|
139
|
+
fields,
|
|
140
|
+
alerts=alerts,
|
|
141
|
+
verbose=args.verbose,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
conn.close()
|
smartscan/config.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""TOML configuration file loading with Pydantic validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import tomllib
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pydantic import ValidationError
|
|
13
|
+
|
|
14
|
+
from .models import SmartScanConfig
|
|
15
|
+
|
|
16
|
+
_ENV_VAR_RE = re.compile(r"\$\{(\w+)\}")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _expand_env_vars(obj: Any) -> Any:
|
|
20
|
+
if isinstance(obj, dict):
|
|
21
|
+
return {k: _expand_env_vars(v) for k, v in obj.items()}
|
|
22
|
+
if isinstance(obj, list):
|
|
23
|
+
return [_expand_env_vars(v) for v in obj]
|
|
24
|
+
if isinstance(obj, str):
|
|
25
|
+
return _ENV_VAR_RE.sub(lambda m: os.environ.get(m.group(1), ""), obj)
|
|
26
|
+
return obj
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_config(config_path: str | None = None) -> SmartScanConfig:
|
|
30
|
+
"""Read a TOML configuration file and return a validated :class:`SmartScanConfig`."""
|
|
31
|
+
from .constants import DEFAULT_CONFIG_PATH
|
|
32
|
+
|
|
33
|
+
path = config_path or DEFAULT_CONFIG_PATH
|
|
34
|
+
expanded = Path(path).expanduser()
|
|
35
|
+
if not expanded.is_file():
|
|
36
|
+
return SmartScanConfig()
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
raw = tomllib.loads(expanded.read_text(encoding="utf-8"))
|
|
40
|
+
except tomllib.TOMLDecodeError as exc:
|
|
41
|
+
logging.warning("Failed to parse config file %s: %s", expanded, exc)
|
|
42
|
+
return SmartScanConfig()
|
|
43
|
+
|
|
44
|
+
if not isinstance(raw, dict):
|
|
45
|
+
logging.warning("Invalid config file format; expected a TOML table.")
|
|
46
|
+
return SmartScanConfig()
|
|
47
|
+
|
|
48
|
+
raw = _expand_env_vars(raw)
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
return SmartScanConfig.model_validate(raw)
|
|
52
|
+
except ValidationError as exc:
|
|
53
|
+
logging.warning("Invalid config values: %s", exc)
|
|
54
|
+
return SmartScanConfig()
|
smartscan/constants.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Default paths, database schema, and error message constants."""
|
|
2
|
+
|
|
3
|
+
DEFAULT_DB_PATH = "~/.local/share/smartscan/smartscan.db"
|
|
4
|
+
DEFAULT_LOG_FILE = "~/.local/state/smartscan/smartscan.log"
|
|
5
|
+
DEFAULT_CONFIG_PATH = "~/.config/smartscan/smartscan.toml"
|
|
6
|
+
|
|
7
|
+
SMARTCTL_ERROR_MSGS = [
|
|
8
|
+
"Bit 0: Command line did not parse.",
|
|
9
|
+
"Bit 1: Device open failed, device did not return an IDENTIFY DEVICE structure, or device is in a low-power mode.",
|
|
10
|
+
"Bit 2: Some SMART or other ATA command to the disk failed, or there was a checksum error in a SMART data structure.",
|
|
11
|
+
"Bit 3: SMART status check returned 'DISK FAILING'.",
|
|
12
|
+
"Bit 4: We found prefail Attributes <= threshold.",
|
|
13
|
+
"Bit 5: SMART status check returned 'DISK OK' but we found that some (usage or prefail) Attributes have been <= threshold at some time in the past.",
|
|
14
|
+
"Bit 6: The device error log contains records of errors.",
|
|
15
|
+
"Bit 7: The device self-test log contains records of errors. [ATA only] Failed self-tests outdated by a newer successful extended self-test are ignored.",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
DB_SCHEMA = """
|
|
19
|
+
CREATE TABLE IF NOT EXISTS smart_info (
|
|
20
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
21
|
+
timestamp TEXT NOT NULL,
|
|
22
|
+
disk_name TEXT NOT NULL,
|
|
23
|
+
disk_path TEXT NOT NULL,
|
|
24
|
+
model_family TEXT,
|
|
25
|
+
model_name TEXT,
|
|
26
|
+
serial_number TEXT,
|
|
27
|
+
firmware_version TEXT,
|
|
28
|
+
user_capacity_bytes INTEGER,
|
|
29
|
+
user_capacity_gib REAL,
|
|
30
|
+
rotation_rate TEXT,
|
|
31
|
+
interface_speed TEXT,
|
|
32
|
+
power_on_time_hours TEXT,
|
|
33
|
+
power_cycle_count TEXT,
|
|
34
|
+
smart_status TEXT,
|
|
35
|
+
temperature_celsius TEXT,
|
|
36
|
+
reallocated_sector_ct TEXT,
|
|
37
|
+
current_pending_sector TEXT,
|
|
38
|
+
offline_uncorrectable TEXT,
|
|
39
|
+
reallocated_event_count TEXT,
|
|
40
|
+
ata_smart_error_log_count TEXT,
|
|
41
|
+
self_test_status TEXT,
|
|
42
|
+
udma_crc_error_count TEXT,
|
|
43
|
+
raw_read_error_rate TEXT,
|
|
44
|
+
spin_retry_count TEXT,
|
|
45
|
+
power_off_retract_count TEXT,
|
|
46
|
+
load_cycle_count TEXT,
|
|
47
|
+
helium_level TEXT,
|
|
48
|
+
raw_json TEXT,
|
|
49
|
+
llm_analysis TEXT
|
|
50
|
+
);
|
|
51
|
+
CREATE INDEX IF NOT EXISTS idx_smart_info_disk_ts
|
|
52
|
+
ON smart_info(disk_path, timestamp);
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
DB_MIGRATIONS = [
|
|
56
|
+
"ALTER TABLE smart_info ADD COLUMN serial_number TEXT",
|
|
57
|
+
"ALTER TABLE smart_info ADD COLUMN firmware_version TEXT",
|
|
58
|
+
"ALTER TABLE smart_info ADD COLUMN smart_status TEXT",
|
|
59
|
+
"ALTER TABLE smart_info ADD COLUMN current_pending_sector TEXT",
|
|
60
|
+
"ALTER TABLE smart_info ADD COLUMN offline_uncorrectable TEXT",
|
|
61
|
+
"ALTER TABLE smart_info ADD COLUMN reallocated_event_count TEXT",
|
|
62
|
+
"ALTER TABLE smart_info ADD COLUMN udma_crc_error_count TEXT",
|
|
63
|
+
"ALTER TABLE smart_info ADD COLUMN raw_read_error_rate TEXT",
|
|
64
|
+
"ALTER TABLE smart_info ADD COLUMN spin_retry_count TEXT",
|
|
65
|
+
"ALTER TABLE smart_info ADD COLUMN power_off_retract_count TEXT",
|
|
66
|
+
"ALTER TABLE smart_info ADD COLUMN load_cycle_count TEXT",
|
|
67
|
+
"ALTER TABLE smart_info ADD COLUMN helium_level TEXT",
|
|
68
|
+
"ALTER TABLE smart_info ADD COLUMN llm_analysis TEXT",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
DEFAULT_SYSTEM_PROMPT = """\
|
|
72
|
+
You are a hard drive health diagnostic expert analyzing SMART data.
|
|
73
|
+
Examine the provided SMART attributes and give a concise assessment:
|
|
74
|
+
|
|
75
|
+
1. Overall health status: HEALTHY / WARNING / CRITICAL
|
|
76
|
+
2. Key concerns with specific metric values (if any)
|
|
77
|
+
3. Recommended action (one sentence)
|
|
78
|
+
|
|
79
|
+
Be factual and conservative. Do not cause unnecessary alarm for borderline values.
|
|
80
|
+
If all metrics are within normal ranges, state the drive is healthy.
|
|
81
|
+
If you cannot make a definitive assessment, say so honestly.
|
|
82
|
+
|
|
83
|
+
Additionally, check the self-test log for the most recent long (extended)
|
|
84
|
+
self-test and compare its lifetime hours against the drive's total power-on
|
|
85
|
+
hours. If a significant portion of the drive's lifetime has passed since the
|
|
86
|
+
last long self-test, recommend running one."""
|
smartscan/database.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""SQLite database operations: init, save, query for SMART history."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
import sqlite3
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .constants import DB_MIGRATIONS, DB_SCHEMA
|
|
14
|
+
from .exceptions import InvalidDateError
|
|
15
|
+
from .models import SmartInfo
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _run_migrations(conn: sqlite3.Connection) -> None:
|
|
19
|
+
for sql in DB_MIGRATIONS:
|
|
20
|
+
try:
|
|
21
|
+
conn.execute(sql)
|
|
22
|
+
except sqlite3.OperationalError as exc:
|
|
23
|
+
if "duplicate column" not in str(exc):
|
|
24
|
+
logging.warning("Migration failed: %s", exc)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def init_db(db_path: str) -> sqlite3.Connection:
|
|
28
|
+
"""Create or open the SQLite database, applying the schema and enabling WAL mode."""
|
|
29
|
+
expanded = Path(db_path).expanduser()
|
|
30
|
+
expanded.parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
conn = sqlite3.connect(str(expanded))
|
|
32
|
+
conn.row_factory = sqlite3.Row
|
|
33
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
34
|
+
conn.executescript(DB_SCHEMA)
|
|
35
|
+
_run_migrations(conn)
|
|
36
|
+
return conn
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def open_db(db_path: str) -> sqlite3.Connection | None:
|
|
40
|
+
"""Open the database in read-only mode for querying, returning ``None`` if it doesn't exist."""
|
|
41
|
+
expanded = Path(db_path).expanduser()
|
|
42
|
+
if not expanded.is_file():
|
|
43
|
+
logging.warning("Database not found: %s", expanded)
|
|
44
|
+
return None
|
|
45
|
+
conn = sqlite3.connect(f"file:{expanded}?mode=ro", uri=True)
|
|
46
|
+
conn.row_factory = sqlite3.Row
|
|
47
|
+
return conn
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def register_regexp(conn: sqlite3.Connection) -> None:
|
|
51
|
+
def _regexp(pattern: str, value: str | None) -> bool:
|
|
52
|
+
if value is None:
|
|
53
|
+
return False
|
|
54
|
+
return bool(re.search(pattern, value))
|
|
55
|
+
|
|
56
|
+
conn.create_function("REGEXP", 2, _regexp)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse_date(date_str: str) -> datetime:
|
|
60
|
+
"""Parse a ``YYYY-MM-DD`` date string into a UTC-aware datetime.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
InvalidDateError: If the string does not match the expected format.
|
|
64
|
+
"""
|
|
65
|
+
try:
|
|
66
|
+
return datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
|
67
|
+
except ValueError as exc:
|
|
68
|
+
raise InvalidDateError(
|
|
69
|
+
f"Invalid date format: {date_str} (expected YYYY-MM-DD)"
|
|
70
|
+
) from exc
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def query_smart_info(
|
|
74
|
+
conn: sqlite3.Connection,
|
|
75
|
+
pattern: str | None,
|
|
76
|
+
since: datetime | None,
|
|
77
|
+
until: datetime | None,
|
|
78
|
+
) -> list[sqlite3.Row]:
|
|
79
|
+
"""Query historical SMART records with optional disk name regex and date range filters."""
|
|
80
|
+
register_regexp(conn)
|
|
81
|
+
|
|
82
|
+
conditions: list[str] = []
|
|
83
|
+
params: list[str] = []
|
|
84
|
+
|
|
85
|
+
if pattern:
|
|
86
|
+
conditions.append("disk_name REGEXP ?")
|
|
87
|
+
params.append(pattern)
|
|
88
|
+
|
|
89
|
+
if since:
|
|
90
|
+
conditions.append("timestamp >= ?")
|
|
91
|
+
params.append(since.strftime("%Y-%m-%dT00:00:00Z"))
|
|
92
|
+
|
|
93
|
+
if until:
|
|
94
|
+
conditions.append("timestamp <= ?")
|
|
95
|
+
params.append(until.strftime("%Y-%m-%dT23:59:59Z"))
|
|
96
|
+
|
|
97
|
+
where = " AND ".join(conditions) if conditions else "1=1"
|
|
98
|
+
sql = f"SELECT * FROM smart_info WHERE {where} ORDER BY disk_path, timestamp"
|
|
99
|
+
|
|
100
|
+
logging.debug("Query: %s params: %s", sql, params)
|
|
101
|
+
return conn.execute(sql, params).fetchall()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
_COLUMNS_INSERT = (
|
|
105
|
+
"timestamp",
|
|
106
|
+
"disk_name",
|
|
107
|
+
"disk_path",
|
|
108
|
+
"model_family",
|
|
109
|
+
"model_name",
|
|
110
|
+
"serial_number",
|
|
111
|
+
"firmware_version",
|
|
112
|
+
"user_capacity_bytes",
|
|
113
|
+
"user_capacity_gib",
|
|
114
|
+
"rotation_rate",
|
|
115
|
+
"interface_speed",
|
|
116
|
+
"power_on_time_hours",
|
|
117
|
+
"power_cycle_count",
|
|
118
|
+
"smart_status",
|
|
119
|
+
"temperature_celsius",
|
|
120
|
+
"reallocated_sector_ct",
|
|
121
|
+
"current_pending_sector",
|
|
122
|
+
"offline_uncorrectable",
|
|
123
|
+
"reallocated_event_count",
|
|
124
|
+
"ata_smart_error_log_count",
|
|
125
|
+
"self_test_status",
|
|
126
|
+
"udma_crc_error_count",
|
|
127
|
+
"raw_read_error_rate",
|
|
128
|
+
"spin_retry_count",
|
|
129
|
+
"power_off_retract_count",
|
|
130
|
+
"load_cycle_count",
|
|
131
|
+
"helium_level",
|
|
132
|
+
"raw_json",
|
|
133
|
+
"llm_analysis",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def save_to_db(
|
|
138
|
+
conn: sqlite3.Connection,
|
|
139
|
+
disk_name: str,
|
|
140
|
+
disk_path: str,
|
|
141
|
+
fields: SmartInfo,
|
|
142
|
+
raw_data: dict[str, Any],
|
|
143
|
+
llm_analysis: str | None = None,
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Persist a single SMART data record into the database."""
|
|
146
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
147
|
+
placeholders = ", ".join("?" * len(_COLUMNS_INSERT))
|
|
148
|
+
columns = ", ".join(_COLUMNS_INSERT)
|
|
149
|
+
conn.execute(
|
|
150
|
+
f"INSERT INTO smart_info ({columns}) VALUES ({placeholders})",
|
|
151
|
+
(
|
|
152
|
+
timestamp,
|
|
153
|
+
disk_name,
|
|
154
|
+
disk_path,
|
|
155
|
+
fields["model_family"],
|
|
156
|
+
fields["model_name"],
|
|
157
|
+
fields["serial_number"],
|
|
158
|
+
fields["firmware_version"],
|
|
159
|
+
fields["user_capacity_bytes"],
|
|
160
|
+
fields["user_capacity_gib"],
|
|
161
|
+
fields["rotation_rate"],
|
|
162
|
+
fields["interface_speed"],
|
|
163
|
+
fields["power_on_time"],
|
|
164
|
+
fields["power_cycle_count"],
|
|
165
|
+
fields["smart_status"],
|
|
166
|
+
fields["temperature"],
|
|
167
|
+
fields["reallocated_sector_ct"],
|
|
168
|
+
fields["current_pending_sector"],
|
|
169
|
+
fields["offline_uncorrectable"],
|
|
170
|
+
fields["reallocated_event_count"],
|
|
171
|
+
fields["ata_smart_error_log"],
|
|
172
|
+
fields["self_test_status"],
|
|
173
|
+
fields["udma_crc_error_count"],
|
|
174
|
+
fields["raw_read_error_rate"],
|
|
175
|
+
fields["spin_retry_count"],
|
|
176
|
+
fields["power_off_retract_count"],
|
|
177
|
+
fields["load_cycle_count"],
|
|
178
|
+
fields["helium_level"],
|
|
179
|
+
json.dumps(raw_data, ensure_ascii=False),
|
|
180
|
+
llm_analysis,
|
|
181
|
+
),
|
|
182
|
+
)
|
|
183
|
+
conn.commit()
|
smartscan/exceptions.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Custom exception hierarchy for smartscan error handling."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SmartScanError(Exception):
|
|
5
|
+
pass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigError(SmartScanError):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DiskNotFoundError(SmartScanError):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SmartctlError(SmartScanError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DatabaseError(SmartScanError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InvalidDateError(SmartScanError):
|
|
25
|
+
pass
|