smartscan 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (46) hide show
  1. {smartscan-0.2.0 → smartscan-0.3.0}/AGENTS.md +14 -6
  2. {smartscan-0.2.0 → smartscan-0.3.0}/CHANGELOG.md +14 -3
  3. {smartscan-0.2.0 → smartscan-0.3.0}/PKG-INFO +4 -4
  4. {smartscan-0.2.0 → smartscan-0.3.0}/README.md +3 -3
  5. {smartscan-0.2.0 → smartscan-0.3.0}/docs/configuration.md +8 -7
  6. {smartscan-0.2.0 → smartscan-0.3.0}/docs/index.md +7 -7
  7. {smartscan-0.2.0 → smartscan-0.3.0}/src/smartscan/cli.py +19 -19
  8. {smartscan-0.2.0 → smartscan-0.3.0}/src/smartscan/commands.py +67 -55
  9. {smartscan-0.2.0 → smartscan-0.3.0}/src/smartscan/constants.py +1 -38
  10. {smartscan-0.2.0 → smartscan-0.3.0}/src/smartscan/database.py +26 -49
  11. smartscan-0.3.0/src/smartscan/fields.py +289 -0
  12. smartscan-0.3.0/src/smartscan/llm.py +251 -0
  13. {smartscan-0.2.0 → smartscan-0.3.0}/src/smartscan/models.py +3 -3
  14. {smartscan-0.2.0 → smartscan-0.3.0}/src/smartscan/output.py +56 -55
  15. {smartscan-0.2.0 → smartscan-0.3.0}/src/smartscan/smartctl.py +104 -100
  16. {smartscan-0.2.0 → smartscan-0.3.0}/tests/test_cli.py +50 -34
  17. {smartscan-0.2.0 → smartscan-0.3.0}/tests/test_llm.py +33 -26
  18. smartscan-0.2.0/src/smartscan/llm.py +0 -207
  19. {smartscan-0.2.0 → smartscan-0.3.0}/.copier-answers.yaml +0 -0
  20. {smartscan-0.2.0 → smartscan-0.3.0}/.devcontainer/devcontainer.json +0 -0
  21. {smartscan-0.2.0 → smartscan-0.3.0}/.dockerignore +0 -0
  22. {smartscan-0.2.0 → smartscan-0.3.0}/.editorconfig +0 -0
  23. {smartscan-0.2.0 → smartscan-0.3.0}/.github/workflows/docs.yaml +0 -0
  24. {smartscan-0.2.0 → smartscan-0.3.0}/.github/workflows/publish-to-pypi.yaml +0 -0
  25. {smartscan-0.2.0 → smartscan-0.3.0}/.github/workflows/publish-to-test-pypi.yaml +0 -0
  26. {smartscan-0.2.0 → smartscan-0.3.0}/.gitignore +0 -0
  27. {smartscan-0.2.0 → smartscan-0.3.0}/.pre-commit-config.yaml +0 -0
  28. {smartscan-0.2.0 → smartscan-0.3.0}/.prettierignore +0 -0
  29. {smartscan-0.2.0 → smartscan-0.3.0}/.yamlfmt.yaml +0 -0
  30. {smartscan-0.2.0 → smartscan-0.3.0}/Dockerfile +0 -0
  31. {smartscan-0.2.0 → smartscan-0.3.0}/LICENSE +0 -0
  32. {smartscan-0.2.0 → smartscan-0.3.0}/docs/llm-examples.md +0 -0
  33. {smartscan-0.2.0 → smartscan-0.3.0}/justfile +0 -0
  34. {smartscan-0.2.0 → smartscan-0.3.0}/pyproject.toml +0 -0
  35. {smartscan-0.2.0 → smartscan-0.3.0}/ruff.toml +0 -0
  36. {smartscan-0.2.0 → smartscan-0.3.0}/src/smartscan/__init__.py +0 -0
  37. {smartscan-0.2.0 → smartscan-0.3.0}/src/smartscan/__main__.py +0 -0
  38. {smartscan-0.2.0 → smartscan-0.3.0}/src/smartscan/config.py +0 -0
  39. {smartscan-0.2.0 → smartscan-0.3.0}/src/smartscan/exceptions.py +0 -0
  40. {smartscan-0.2.0 → smartscan-0.3.0}/src/smartscan/logging.py +0 -0
  41. {smartscan-0.2.0 → smartscan-0.3.0}/src/smartscan/thresholds.py +0 -0
  42. {smartscan-0.2.0 → smartscan-0.3.0}/tests/conftest.py +0 -0
  43. {smartscan-0.2.0 → smartscan-0.3.0}/tests/test_core.py +0 -0
  44. {smartscan-0.2.0 → smartscan-0.3.0}/tests/test_output.py +0 -0
  45. {smartscan-0.2.0 → smartscan-0.3.0}/uv.lock +0 -0
  46. {smartscan-0.2.0 → smartscan-0.3.0}/zensical.toml +0 -0
@@ -14,10 +14,15 @@ smartscan is a CLI tool that runs `smartctl` on all disk devices, extracts key S
14
14
  - DO NOT use ad hoc `pip install` commands instead of updating `pyproject.toml`.
15
15
  - DO NOT edit generated output under `site/`; rebuild it from source.
16
16
 
17
+ ## Agent Guidelines
18
+
19
+ - Do not read or write files that are excluded by `.gitignore`, unless a skill or the user explicitly directs you to do so.
20
+ - Proactively use available agent skills when a task matches a skill's description.
21
+
17
22
  ## Conventions
18
23
 
19
24
  - Application code lives under `src/smartscan`.
20
- - The CLI uses stdlib `argparse` with `argcomplete` for shell completion. Subcommands: `collect` and `query`.
25
+ - The CLI uses stdlib `argparse` with `argcomplete` for shell completion. Subcommands: `collect`, `query`, and `lsblk`.
21
26
  - Terminal output uses [Rich](https://rich.readthedocs.io/) for styled tables and formatting.
22
27
  - Configuration uses [Pydantic](https://docs.pydantic.dev/) `BaseModel` for validation.
23
28
  - SMART field structures use `TypedDict` (`SmartInfo`) for type safety.
@@ -30,15 +35,18 @@ smartscan is a CLI tool that runs `smartctl` on all disk devices, extracts key S
30
35
 
31
36
  | Module | Responsibility |
32
37
  |---|---|
33
- | `constants.py` | Default paths, DB schema, error messages |
38
+ | `constants.py` | Default paths, database migrations, error messages |
34
39
  | `exceptions.py` | `SmartScanError` hierarchy |
35
40
  | `models.py` | `SmartScanConfig` (Pydantic), `SmartInfo` (TypedDict) |
41
+ | `fields.py` | `FieldDef` registry — single source of truth for field metadata, DB columns, display labels |
36
42
  | `config.py` | `load_config()` — read TOML config into `SmartScanConfig` |
37
43
  | `logging.py` | `setup_logging()` |
38
- | `smartctl.py` | `find_disks()`, `run_smartctl()`, `extract_fields()`, helpers |
39
- | `output.py` | Rich-powered `print_table()`, `print_query_table()`, `print_json_output()` |
40
- | `database.py` | SQLite init, open, save, query |
41
- | `commands.py` | `do_collect()`, `do_query()` |
44
+ | `smartctl.py` | `find_disks()`, `run_smartctl()`, `extract_fields()`, `build_device_tree()`, helpers |
45
+ | `thresholds.py` | `check_thresholds()`, `Alert` dataclass |
46
+ | `output.py` | Rich-powered `print_table()`, `print_query_table()`, `print_json_output()`, `print_lsblk_tree()` |
47
+ | `database.py` | SQLite init, open, save, query, schema generation |
48
+ | `llm.py` | `BaseLLMProvider` → `OpenAIProvider` / `AnthropicProvider`, `call_llm()`, `_build_prompt()` |
49
+ | `commands.py` | `do_collect()`, `do_query()`, `do_lsblk()` |
42
50
  | `cli.py` | `create_parser()`, `main()` |
43
51
 
44
52
  ## Testing Guidelines
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.0] - 2026-06-14
9
+
10
+ ### Added
11
+
12
+ - Global `--exclude` option usable by all subcommands, including `collect`
13
+ - `exclude_patterns` top-level config key shared across subcommands
14
+ - Exclude patterns now match both symlink names and resolved target paths
15
+
16
+ ### Changed
17
+
18
+ - `identify` subcommand renamed to `lsblk` (**breaking**: CLI invocation changes)
19
+ - `exclude_patterns` config key removed from `[lsblk]` section; use top-level key instead (**breaking**: update TOML config)
20
+ - Extracted `fields.py` and `llm.py` into dedicated modules
21
+
8
22
  ## [0.2.0] - 2026-06-13
9
23
 
10
24
  ### Added
@@ -56,6 +70,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
56
70
  - Threshold checks now also run in `query` mode for historical alert display
57
71
  - Database migration errors are now logged with a warning instead of being silently swallowed, except for expected "duplicate column" cases
58
72
  - Docker image now installs `smartmontools` so the `smartctl` binary is available at run time
59
-
60
- [0.2.0]: https://github.com/ak1ra-lab/smartscan/tag/?h=v0.2.0
61
- [0.1.0]: https://github.com/ak1ra-lab/smartscan/tag/?h=v0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: smartscan
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: A CLI tool that collects SMART data from disk devices, checks configurable health thresholds, optionally analyses results with an LLM, and tracks history in SQLite.
5
5
  Project-URL: Homepage, https://github.com/ak1ra-lab/smartscan
6
6
  Project-URL: Repository, https://github.com/ak1ra-lab/smartscan
@@ -55,9 +55,9 @@ smartscan query --since 2026-01-01
55
55
  Map disks to their /dev/disk/ identifiers (no root needed):
56
56
 
57
57
  ```bash
58
- smartscan identify
59
- smartscan identify --source by-id "Samsung"
60
- smartscan identify --exclude '^/dev/(loop|zd)\d+'
58
+ smartscan lsblk
59
+ smartscan lsblk --source by-id "Samsung"
60
+ smartscan lsblk --exclude '^/dev/(loop|zd)\d+'
61
61
  ```
62
62
 
63
63
  Read the [full documentation](https://ak1ra-lab.github.io/smartscan/) for shell completion, configuration reference, and advanced usage.
@@ -32,9 +32,9 @@ smartscan query --since 2026-01-01
32
32
  Map disks to their /dev/disk/ identifiers (no root needed):
33
33
 
34
34
  ```bash
35
- smartscan identify
36
- smartscan identify --source by-id "Samsung"
37
- smartscan identify --exclude '^/dev/(loop|zd)\d+'
35
+ smartscan lsblk
36
+ smartscan lsblk --source by-id "Samsung"
37
+ smartscan lsblk --exclude '^/dev/(loop|zd)\d+'
38
38
  ```
39
39
 
40
40
  Read the [full documentation](https://ak1ra-lab.github.io/smartscan/) for shell completion, configuration reference, and advanced usage.
@@ -13,6 +13,12 @@ db_path = "~/.local/share/smartscan/smartscan.db"
13
13
  # Custom log file path
14
14
  log_file = "~/.local/state/smartscan/smartscan.log"
15
15
 
16
+ # Regex patterns to exclude disk devices by symlink name or resolved path.
17
+ # Applies to both `collect` and `lsblk` subcommands.
18
+ # Useful for filtering out optical drives, loop devices, ZFS zvols, etc.
19
+ # Example: exclude_patterns = ["BD-RE", '^/dev/(loop|zd)\d+']
20
+ exclude_patterns = []
21
+
16
22
  # ── collect ────────────────────────────────────────────────────
17
23
  [collect]
18
24
  # Skip database writes (run once without saving)
@@ -29,17 +35,12 @@ no_save = false
29
35
  # Show only records from the last N days (overrides `since`)
30
36
  # last_days = 7
31
37
 
32
- # ── identify ────────────────────────────────────────────────────
33
- [identify]
38
+ # ── lsblk ────────────────────────────────────────────────────
39
+ [lsblk]
34
40
  # Restrict to specific /dev/disk/ source directories
35
41
  # (by-id, by-path, by-diskseq; default: all when empty)
36
42
  # source = ["by-id", "by-path"]
37
43
 
38
- # Regex patterns to exclude resolved device paths.
39
- # Useful for filtering out loop devices, ZFS zvols, etc.
40
- # Example: exclude_patterns = ["^/dev/(loop|zd)\\d+"]
41
- exclude_patterns = []
42
-
43
44
  # ── Threshold alerts ────────────────────────────────────────────
44
45
  [thresholds]
45
46
  # Master switch: set to false to disable all threshold checks
@@ -6,7 +6,7 @@ A CLI tool that runs `smartctl` on all disks, extracts key SMART health metrics,
6
6
 
7
7
  - Collects SMART data via `smartctl --all --json` from `/dev/disk/by-id/ata-*` and `/dev/disk/by-id/nvme-*`.
8
8
  - Displays results as Rich-styled tables with warnings for critical values.
9
- - Maps disk devices to their `/dev/disk/` identifiers (by-id, by-path, by-diskseq) with a Rich tree view (`identify` subcommand).
9
+ - Maps disk devices to their `/dev/disk/` identifiers (by-id, by-path, by-diskseq) with a Rich tree view (`lsblk` subcommand).
10
10
  - Stores historical data in SQLite for trend analysis (`query` subcommand).
11
11
  - Supports JSON lines output for scripting.
12
12
  - Configurable via TOML config file with Pydantic validation.
@@ -57,14 +57,14 @@ smartscan --json collect
57
57
  smartscan --json query --since 2024-01-01
58
58
  ```
59
59
 
60
- `smartscan identify` maps block devices to their identifiers under `/dev/disk/`. It does not require root:
60
+ `smartscan lsblk` maps block devices to their identifiers under `/dev/disk/`. It does not require root:
61
61
 
62
62
  ```shell
63
- smartscan identify
64
- smartscan identify "Samsung"
65
- smartscan identify --source by-id
66
- smartscan identify --source by-id --source by-diskseq
67
- smartscan --json identify
63
+ smartscan lsblk
64
+ smartscan lsblk "Samsung"
65
+ smartscan lsblk --source by-id
66
+ smartscan lsblk --source by-id --source by-diskseq
67
+ smartscan --json lsblk
68
68
  ```
69
69
 
70
70
  Output example:
@@ -10,7 +10,7 @@ import sys
10
10
 
11
11
  import argcomplete
12
12
 
13
- from .commands import do_collect, do_identify, do_query
13
+ from .commands import do_collect, do_lsblk, do_query
14
14
  from .config import load_config
15
15
  from .constants import DEFAULT_CONFIG_PATH, DEFAULT_DB_PATH, DEFAULT_LOG_FILE
16
16
  from .exceptions import SmartScanError
@@ -52,6 +52,15 @@ def _build_parser(config: SmartScanConfig | None = None) -> argparse.ArgumentPar
52
52
  default=cfg.format == "json",
53
53
  help="Output as JSON lines instead of table format",
54
54
  )
55
+ parser.add_argument(
56
+ "--exclude",
57
+ dest="exclude_patterns",
58
+ action="append",
59
+ metavar="PATTERN",
60
+ default=cfg.exclude_patterns,
61
+ help="Regex patterns to exclude disk devices by symlink name or resolved path "
62
+ "(may be repeated; appended to config exclude_patterns)",
63
+ )
55
64
 
56
65
  sub = parser.add_subparsers(dest="command", required=True)
57
66
 
@@ -114,33 +123,24 @@ def _build_parser(config: SmartScanConfig | None = None) -> argparse.ArgumentPar
114
123
  help="Show extended fields in terminal output",
115
124
  )
116
125
 
117
- identify_parser = sub.add_parser(
118
- "identify",
126
+ lsblk_parser = sub.add_parser(
127
+ "lsblk",
119
128
  help="Map disk devices to their /dev/disk identifiers",
120
129
  )
121
- identify_parser.add_argument(
130
+ lsblk_parser.add_argument(
122
131
  "pattern",
123
132
  nargs="?",
124
133
  default=".*",
125
134
  help="Regex pattern to filter identifier names",
126
135
  )
127
- identify_parser.add_argument(
136
+ lsblk_parser.add_argument(
128
137
  "--source",
129
- dest="identify_source",
138
+ dest="lsblk_source",
130
139
  action="append",
131
140
  choices=["by-id", "by-path", "by-diskseq"],
132
141
  default=None,
133
142
  help="Restrict to specific /dev/disk/ source directories "
134
- "(appended to identify.source config; may be repeated; default: all)",
135
- )
136
- identify_parser.add_argument(
137
- "--exclude",
138
- dest="identify_exclude",
139
- action="append",
140
- metavar="PATTERN",
141
- default=None,
142
- help="Additional regex patterns to exclude resolved device paths "
143
- "(appended to identify.exclude_patterns config; may be repeated)",
143
+ "(appended to lsblk.source config; may be repeated; default: all)",
144
144
  )
145
145
 
146
146
  argcomplete.autocomplete(parser)
@@ -166,12 +166,12 @@ def main() -> None:
166
166
  args.llm_config = config.llm
167
167
  args.collect_config = config.collect
168
168
  args.query_config = config.query
169
- args.identify_config = config.identify
169
+ args.lsblk_config = config.lsblk
170
170
 
171
171
  if args.command == "query":
172
172
  do_query(args)
173
- elif args.command == "identify":
174
- do_identify(args)
173
+ elif args.command == "lsblk":
174
+ do_lsblk(args)
175
175
  else:
176
176
  do_collect(args)
177
177
  except SmartScanError as exc:
@@ -8,49 +8,100 @@ import sqlite3
8
8
  import sys
9
9
  import time
10
10
  from argparse import Namespace
11
+ from typing import Any
11
12
 
12
13
  from .database import init_db, open_db, query_smart_info, save_to_db
13
14
  from .exceptions import DiskNotFoundError
14
15
  from .llm import call_llm
15
16
  from .output import (
16
- print_identify_json,
17
- print_identify_tree,
18
17
  print_json_output,
19
18
  print_llm_analysis,
19
+ print_lsblk_json,
20
+ print_lsblk_tree,
20
21
  print_query_table,
21
22
  print_table,
22
23
  row_to_fields,
23
24
  )
24
25
  from .smartctl import build_device_tree, extract_fields, find_disks, run_smartctl
25
- from .thresholds import check_thresholds
26
+ from .thresholds import Alert, check_thresholds
26
27
 
27
28
 
28
- def do_identify(args: Namespace) -> None:
29
+ def _build_alerts_text(alerts: list[Alert]) -> str:
30
+ if not alerts:
31
+ return " (none)"
32
+ return "\n".join(f" - {a.message}" for a in alerts)
33
+
34
+
35
+ def _collect_one_disk(
36
+ disk_idx: int,
37
+ total_disks: int,
38
+ symlink: Any,
39
+ conn: sqlite3.Connection | None,
40
+ args: Namespace,
41
+ ) -> int:
42
+ disk_name = symlink.name
43
+ disk_path = os.readlink(symlink)
44
+
45
+ data, rc = run_smartctl(symlink)
46
+ if not data:
47
+ return rc
48
+
49
+ fields = extract_fields(data)
50
+
51
+ alerts: list[Alert] = []
52
+ if args.thresholds_enabled:
53
+ alerts = check_thresholds(fields, args.threshold_rules)
54
+
55
+ if args.json:
56
+ print_json_output(disk_name, disk_path, fields, data)
57
+ else:
58
+ print_table(disk_name, fields, alerts, verbose=args.verbose)
59
+
60
+ llm_analysis = None
61
+ if args.force_llm or (args.llm_config.enabled and alerts):
62
+ alerts_text = _build_alerts_text(alerts)
63
+ llm_analysis = call_llm(fields, alerts_text, args.llm_config, raw_data=data)
64
+ if llm_analysis:
65
+ if not args.json:
66
+ print_llm_analysis(llm_analysis)
67
+ if args.llm_config.delay > 0 and disk_idx < total_disks - 1:
68
+ time.sleep(args.llm_config.delay)
69
+
70
+ if conn:
71
+ try:
72
+ save_to_db(
73
+ conn, disk_name, disk_path, fields, data, llm_analysis=llm_analysis
74
+ )
75
+ except sqlite3.Error as exc:
76
+ logging.error("Failed to save SMART data for %s: %s", disk_name, exc)
77
+
78
+ return rc
79
+
80
+
81
+ def do_lsblk(args: Namespace) -> None:
29
82
  from .smartctl import _SOURCE_DIRS
30
83
 
31
- config_cfg = args.identify_config
84
+ config_cfg = args.lsblk_config
32
85
 
33
86
  sources_list = list(config_cfg.source or _SOURCE_DIRS)
34
- if args.identify_source:
35
- sources_list.extend(args.identify_source)
87
+ if args.lsblk_source:
88
+ sources_list.extend(args.lsblk_source)
36
89
  sources = tuple(sources_list)
37
90
 
38
- exclude_list = list(config_cfg.exclude_patterns)
39
- if args.identify_exclude:
40
- exclude_list.extend(args.identify_exclude)
41
-
42
91
  try:
43
92
  devices = build_device_tree(
44
- args.pattern, sources=sources, exclude_patterns=exclude_list or None
93
+ args.pattern,
94
+ sources=sources,
95
+ exclude_patterns=args.exclude_patterns or None,
45
96
  )
46
97
  except DiskNotFoundError as exc:
47
98
  logging.error("%s", exc)
48
99
  sys.exit(1)
49
100
 
50
101
  if args.json:
51
- print_identify_json(devices)
102
+ print_lsblk_json(devices)
52
103
  else:
53
- print_identify_tree(devices)
104
+ print_lsblk_tree(devices)
54
105
 
55
106
 
56
107
  def do_collect(args: Namespace) -> None:
@@ -67,56 +118,17 @@ def do_collect(args: Namespace) -> None:
67
118
  logging.error("Failed to open database at %s: %s", args.db_path, exc)
68
119
 
69
120
  try:
70
- disks = find_disks(args.pattern)
121
+ disks = find_disks(args.pattern, exclude_patterns=args.exclude_patterns or None)
71
122
  except DiskNotFoundError as exc:
72
123
  logging.error("%s", exc)
73
124
  sys.exit(1)
74
125
 
75
- thresholds_enabled = args.thresholds_enabled
76
-
77
126
  exit_code = 0
78
127
  for disk_idx, symlink in enumerate(disks):
79
- disk_name = symlink.name
80
- disk_path = os.readlink(symlink)
81
-
82
- data, rc = run_smartctl(symlink)
128
+ rc = _collect_one_disk(disk_idx, len(disks), symlink, conn, args)
83
129
  if rc != 0:
84
130
  exit_code = 1
85
131
 
86
- if not data:
87
- continue
88
-
89
- fields = extract_fields(data)
90
-
91
- alerts = []
92
- if thresholds_enabled:
93
- alerts = check_thresholds(fields, args.threshold_rules)
94
-
95
- if args.json:
96
- print_json_output(disk_name, disk_path, fields, data)
97
- else:
98
- print_table(disk_name, fields, alerts, verbose=args.verbose)
99
-
100
- llm_analysis = None
101
- if args.force_llm or (args.llm_config.enabled and alerts):
102
- alerts_text = (
103
- "\n".join(f" - {a.message}" for a in alerts) if alerts else " (none)"
104
- )
105
- llm_analysis = call_llm(fields, alerts_text, args.llm_config, raw_data=data)
106
- if llm_analysis:
107
- if not args.json:
108
- print_llm_analysis(llm_analysis)
109
- if args.llm_config.delay > 0 and disk_idx < len(disks) - 1:
110
- time.sleep(args.llm_config.delay)
111
-
112
- if conn:
113
- try:
114
- save_to_db(
115
- conn, disk_name, disk_path, fields, data, llm_analysis=llm_analysis
116
- )
117
- except sqlite3.Error as exc:
118
- logging.error("Failed to save SMART data for %s: %s", disk_name, exc)
119
-
120
132
  if conn:
121
133
  conn.close()
122
134
 
@@ -1,4 +1,4 @@
1
- """Default paths, database schema, and error message constants."""
1
+ """Default paths, database migration constants, and error messages."""
2
2
 
3
3
  DEFAULT_DB_PATH = "~/.local/share/smartscan/smartscan.db"
4
4
  DEFAULT_LOG_FILE = "~/.local/state/smartscan/smartscan.log"
@@ -15,43 +15,6 @@ SMARTCTL_ERROR_MSGS = [
15
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
16
  ]
17
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
18
  DB_MIGRATIONS = [
56
19
  "ALTER TABLE smart_info ADD COLUMN serial_number TEXT",
57
20
  "ALTER TABLE smart_info ADD COLUMN firmware_version TEXT",
@@ -10,11 +10,34 @@ from datetime import datetime, timezone
10
10
  from pathlib import Path
11
11
  from typing import Any
12
12
 
13
- from .constants import DB_MIGRATIONS, DB_SCHEMA
13
+ from .constants import DB_MIGRATIONS
14
14
  from .exceptions import InvalidDateError
15
+ from .fields import ALL_HEALTH_FIELDS, get_field
15
16
  from .models import SmartInfo
16
17
 
17
18
 
19
+ def _build_schema() -> str:
20
+ col_defs = [
21
+ " id INTEGER PRIMARY KEY AUTOINCREMENT",
22
+ " timestamp TEXT NOT NULL",
23
+ " disk_name TEXT NOT NULL",
24
+ " disk_path TEXT NOT NULL",
25
+ ]
26
+ for f in ALL_HEALTH_FIELDS:
27
+ col_defs.append(f" {f.db_column} {f.db_type}")
28
+ col_defs.append(" raw_json TEXT")
29
+ col_defs.append(" llm_analysis TEXT")
30
+ cols = ",\n".join(col_defs)
31
+ return (
32
+ f"CREATE TABLE IF NOT EXISTS smart_info (\n{cols}\n);\n"
33
+ "CREATE INDEX IF NOT EXISTS idx_smart_info_disk_ts\n"
34
+ " ON smart_info(disk_path, timestamp);"
35
+ )
36
+
37
+
38
+ DB_SCHEMA = _build_schema()
39
+
40
+
18
41
  def _run_migrations(conn: sqlite3.Connection) -> None:
19
42
  for sql in DB_MIGRATIONS:
20
43
  try:
@@ -105,30 +128,7 @@ _COLUMNS_INSERT = (
105
128
  "timestamp",
106
129
  "disk_name",
107
130
  "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",
131
+ *(f.db_column for f in ALL_HEALTH_FIELDS),
132
132
  "raw_json",
133
133
  "llm_analysis",
134
134
  )
@@ -152,30 +152,7 @@ def save_to_db(
152
152
  timestamp,
153
153
  disk_name,
154
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"],
155
+ *(get_field(fields, f.key) for f in ALL_HEALTH_FIELDS),
179
156
  json.dumps(raw_data, ensure_ascii=False),
180
157
  llm_analysis,
181
158
  ),