dwipe 2.0.2__py3-none-any.whl → 3.0.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.
- dwipe/DeviceChangeMonitor.py +244 -0
- dwipe/DeviceInfo.py +589 -194
- dwipe/DeviceWorker.py +566 -0
- dwipe/DiskWipe.py +558 -134
- dwipe/DrivePreChecker.py +161 -48
- dwipe/FirmwareWipeTask.py +627 -132
- dwipe/NvmeTool.py +225 -0
- dwipe/PersistentState.py +20 -9
- dwipe/SataTool.py +499 -0
- dwipe/StructuredLogger.py +4 -3
- dwipe/Tunables.py +62 -0
- dwipe/Utils.py +192 -5
- dwipe/VerifyTask.py +4 -2
- dwipe/WipeJob.py +25 -13
- dwipe/WipeTask.py +4 -2
- dwipe/WriteTask.py +1 -1
- dwipe/main.py +28 -8
- {dwipe-2.0.2.dist-info → dwipe-3.0.0.dist-info}/METADATA +219 -99
- dwipe-3.0.0.dist-info/RECORD +24 -0
- dwipe/LsblkMonitor.py +0 -124
- dwipe/ToolManager.py +0 -618
- dwipe-2.0.2.dist-info/RECORD +0 -21
- {dwipe-2.0.2.dist-info → dwipe-3.0.0.dist-info}/WHEEL +0 -0
- {dwipe-2.0.2.dist-info → dwipe-3.0.0.dist-info}/entry_points.txt +0 -0
- {dwipe-2.0.2.dist-info → dwipe-3.0.0.dist-info}/licenses/LICENSE +0 -0
dwipe/DeviceInfo.py
CHANGED
|
@@ -10,8 +10,6 @@ import os
|
|
|
10
10
|
import re
|
|
11
11
|
import json
|
|
12
12
|
import subprocess
|
|
13
|
-
import time
|
|
14
|
-
import datetime
|
|
15
13
|
import curses
|
|
16
14
|
import traceback
|
|
17
15
|
from fnmatch import fnmatch
|
|
@@ -19,22 +17,61 @@ from types import SimpleNamespace
|
|
|
19
17
|
from console_window import Theme
|
|
20
18
|
from dataclasses import asdict
|
|
21
19
|
|
|
22
|
-
from .WipeJob import WipeJob
|
|
23
20
|
from .Utils import Utils
|
|
24
21
|
from .DrivePreChecker import DrivePreChecker
|
|
22
|
+
from .DeviceWorker import DeviceWorkerManager, ProbeState
|
|
25
23
|
|
|
26
24
|
|
|
27
25
|
class DeviceInfo:
|
|
28
26
|
"""Class to dig out the info we want from the system."""
|
|
29
27
|
disk_majors = set() # major devices that are disks
|
|
28
|
+
_discovery_cycle = 0 # Counter to prove discovery is running
|
|
30
29
|
|
|
31
|
-
|
|
30
|
+
@staticmethod
|
|
31
|
+
def clean_partition_label(label):
|
|
32
|
+
"""Clean up partition labels by decoding escape sequences and simplifying common labels.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
label: Raw partition label string (may contain \x20 etc.)
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Cleaned up label string
|
|
39
|
+
"""
|
|
40
|
+
if not label:
|
|
41
|
+
return ''
|
|
42
|
+
|
|
43
|
+
# Decode escape sequences (like \x20 for space)
|
|
44
|
+
try:
|
|
45
|
+
# Handle common escape sequences
|
|
46
|
+
cleaned = label.encode('utf-8').decode('unicode_escape')
|
|
47
|
+
except (UnicodeDecodeError, AttributeError):
|
|
48
|
+
cleaned = label
|
|
49
|
+
|
|
50
|
+
# Simplify common Windows partition labels
|
|
51
|
+
simplifications = {
|
|
52
|
+
'Basic data partition': 'MS-Data',
|
|
53
|
+
'Microsoft reserved partition': 'MS-Reserved',
|
|
54
|
+
'EFI system partition': 'EFI',
|
|
55
|
+
'EFI System Partition': 'EFI',
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
cleaned = simplifications.get(cleaned, cleaned)
|
|
59
|
+
|
|
60
|
+
return cleaned
|
|
61
|
+
|
|
62
|
+
def __init__(self, opts, persistent_state=None, worker_manager=None):
|
|
32
63
|
self.opts = opts
|
|
33
64
|
self.checker = DrivePreChecker()
|
|
34
65
|
self.wids = SimpleNamespace(state=5, name=4, human=7, fstype=4, label=5)
|
|
35
66
|
self.head_str = None
|
|
36
67
|
self.partitions = None
|
|
37
68
|
self.persistent_state = persistent_state
|
|
69
|
+
# Use provided worker_manager or create a new one
|
|
70
|
+
# Reusing allows hw_caps probing to continue across device refreshes
|
|
71
|
+
if worker_manager is not None:
|
|
72
|
+
self.worker_manager = worker_manager
|
|
73
|
+
else:
|
|
74
|
+
self.worker_manager = DeviceWorkerManager(self.checker)
|
|
38
75
|
|
|
39
76
|
@staticmethod
|
|
40
77
|
def _make_partition_namespace(major, name, size_bytes, dflt):
|
|
@@ -48,42 +85,51 @@ class DeviceInfo:
|
|
|
48
85
|
type='', # device type (disk, part)
|
|
49
86
|
model='', # /sys/class/block/{name}/device/vendor|model
|
|
50
87
|
size_bytes=size_bytes, # /sys/block/{name}/...
|
|
51
|
-
marker='', #
|
|
52
|
-
|
|
88
|
+
marker='', # Formatted marker string from worker thread
|
|
89
|
+
want_marker=False, # True = ask worker thread to poll for marker
|
|
53
90
|
mounts=[], # /proc/mounts
|
|
54
91
|
minors=[],
|
|
55
92
|
job=None, # if zap running
|
|
56
93
|
uuid='', # filesystem UUID or PARTUUID
|
|
57
94
|
serial='', # disk serial number (for whole disks)
|
|
58
95
|
port='', # port (for whole disks)
|
|
59
|
-
hw_caps=
|
|
60
|
-
|
|
96
|
+
hw_caps='', # hw_wipe capabilities string: "Ovwr, Block, Crypto*"
|
|
97
|
+
hw_caps_summary='', # compact display: "ϟCrypto"
|
|
98
|
+
hw_nopes='', # hw_wipe issues string: "Frozen, Locked"
|
|
99
|
+
hw_caps_state=ProbeState.PENDING, # probe state for hw_caps
|
|
100
|
+
is_usb=False, # True if device is on USB bus
|
|
101
|
+
is_rotational=False, # True if HDD (spinning disk)
|
|
61
102
|
)
|
|
62
103
|
|
|
63
104
|
def get_hw_capabilities(self, ns):
|
|
64
105
|
"""
|
|
65
106
|
Populates and returns hardware wipe capabilities for a disk.
|
|
66
|
-
|
|
107
|
+
Non-blocking: requests probe from background worker, returns cached state.
|
|
108
|
+
|
|
109
|
+
IMPORTANT: Skips probing if device has an active job to avoid blocking.
|
|
67
110
|
"""
|
|
68
|
-
# 1. Check if we already have
|
|
69
|
-
if
|
|
111
|
+
# 1. Check if we already have final results
|
|
112
|
+
if ns.hw_caps_state == ProbeState.READY:
|
|
70
113
|
return ns.hw_caps, ns.hw_nopes
|
|
71
114
|
|
|
72
|
-
#
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
# Skip hardware checks if firmware wipes are disabled
|
|
76
|
-
if not getattr(self.opts, 'firmware_wipes', False):
|
|
115
|
+
# 2. Skip probing if device has active job (would block on SATA wipe)
|
|
116
|
+
if ns.job:
|
|
77
117
|
return ns.hw_caps, ns.hw_nopes
|
|
78
118
|
|
|
79
|
-
#
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
# 5.
|
|
86
|
-
ns.hw_caps
|
|
119
|
+
# 3. Request probe from worker (non-blocking)
|
|
120
|
+
self.worker_manager.request_hw_caps(ns.name)
|
|
121
|
+
|
|
122
|
+
# 4. Get current state from worker
|
|
123
|
+
hw_caps, hw_caps_summary, hw_nopes, state, is_usb, is_rotational = self.worker_manager.get_hw_caps(ns.name)
|
|
124
|
+
|
|
125
|
+
# 5. Update namespace with worker state
|
|
126
|
+
ns.hw_caps = hw_caps
|
|
127
|
+
ns.hw_caps_summary = hw_caps_summary
|
|
128
|
+
ns.hw_nopes = hw_nopes
|
|
129
|
+
ns.hw_caps_state = state
|
|
130
|
+
ns.is_usb = is_usb
|
|
131
|
+
ns.is_rotational = is_rotational
|
|
132
|
+
|
|
87
133
|
return ns.hw_caps, ns.hw_nopes
|
|
88
134
|
|
|
89
135
|
def _get_port_from_sysfs(self, device_name):
|
|
@@ -144,7 +190,8 @@ class DeviceInfo:
|
|
|
144
190
|
rv = ''
|
|
145
191
|
fullpath = f'/sys/class/block/{device_name}/device/{suffix}'
|
|
146
192
|
with open(fullpath, 'r', encoding='utf-8') as f: # Read information
|
|
147
|
-
|
|
193
|
+
# Sanitize: some USB bridges return strings with embedded nulls
|
|
194
|
+
rv = f.read().strip().replace('\x00', '')
|
|
148
195
|
except (FileNotFoundError, Exception):
|
|
149
196
|
pass
|
|
150
197
|
return rv
|
|
@@ -152,173 +199,441 @@ class DeviceInfo:
|
|
|
152
199
|
rv = f'{get_str(device_name, "model")}'
|
|
153
200
|
return rv.strip()
|
|
154
201
|
|
|
155
|
-
|
|
156
|
-
|
|
202
|
+
@staticmethod
|
|
203
|
+
def _is_rotational_device(device_name):
|
|
204
|
+
"""Check if device is rotational (HDD) vs solid-state (SSD).
|
|
205
|
+
|
|
206
|
+
Reads /sys/block/<device>/queue/rotational:
|
|
207
|
+
- 1 = HDD (spinning disk)
|
|
208
|
+
- 0 = SSD (solid-state)
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
bool: True if HDD (rotational), False if SSD or unknown
|
|
212
|
+
"""
|
|
213
|
+
try:
|
|
214
|
+
# For partitions, get the parent disk name
|
|
215
|
+
parent = device_name.rstrip('0123456789')
|
|
216
|
+
if parent.endswith('p') and parent[:-1].rstrip('0123456789'):
|
|
217
|
+
# NVMe style: nvme0n1p1 -> nvme0n1
|
|
218
|
+
parent = parent[:-1]
|
|
219
|
+
if not parent:
|
|
220
|
+
parent = device_name
|
|
221
|
+
|
|
222
|
+
path = f'/sys/block/{parent}/queue/rotational'
|
|
223
|
+
with open(path, 'r', encoding='utf-8') as f:
|
|
224
|
+
return f.read().strip() == '1'
|
|
225
|
+
except (FileNotFoundError, PermissionError, OSError):
|
|
226
|
+
return False
|
|
227
|
+
|
|
228
|
+
# ========================================================================
|
|
229
|
+
# Non-blocking device discovery helpers (replacement for lsblk)
|
|
230
|
+
# ========================================================================
|
|
231
|
+
|
|
232
|
+
def _parse_proc_partitions(self):
|
|
233
|
+
"""Parse /proc/partitions for basic device list (non-blocking).
|
|
234
|
+
|
|
235
|
+
Format: major minor #blocks name
|
|
236
|
+
8 0 1048576 sda
|
|
237
|
+
8 1 524288 sda1
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
dict: {name: SimpleNamespace(major, minor, blocks, name)}
|
|
241
|
+
"""
|
|
242
|
+
devices = {}
|
|
243
|
+
try:
|
|
244
|
+
with open('/proc/partitions', 'r', encoding='utf-8') as f:
|
|
245
|
+
for line in f:
|
|
246
|
+
parts = line.split()
|
|
247
|
+
if len(parts) == 4 and parts[0].isdigit():
|
|
248
|
+
major, minor, blocks, name = parts
|
|
249
|
+
devices[name] = SimpleNamespace(
|
|
250
|
+
major=int(major),
|
|
251
|
+
minor=int(minor),
|
|
252
|
+
blocks=int(blocks), # In KB
|
|
253
|
+
name=name
|
|
254
|
+
)
|
|
255
|
+
except (FileNotFoundError, PermissionError, Exception):
|
|
256
|
+
pass
|
|
257
|
+
return devices
|
|
258
|
+
|
|
259
|
+
def _parse_proc_mounts(self):
|
|
260
|
+
"""Parse /proc/mounts for mount points (non-blocking).
|
|
261
|
+
|
|
262
|
+
Format: device mountpoint fstype options dump pass
|
|
263
|
+
/dev/sda1 /boot ext4 rw,relatime 0 0
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
dict: {device_name: {'mounts': [mountpoint1, ...], 'fstype': str}}
|
|
267
|
+
"""
|
|
268
|
+
mounts = {}
|
|
269
|
+
try:
|
|
270
|
+
with open('/proc/mounts', 'r', encoding='utf-8') as f:
|
|
271
|
+
for line in f:
|
|
272
|
+
parts = line.split()
|
|
273
|
+
if len(parts) >= 3 and parts[0].startswith('/dev/'):
|
|
274
|
+
device = parts[0][5:] # Strip '/dev/'
|
|
275
|
+
mountpoint = parts[1]
|
|
276
|
+
fstype = parts[2]
|
|
277
|
+
if device not in mounts:
|
|
278
|
+
mounts[device] = {'mounts': [], 'fstype': fstype}
|
|
279
|
+
mounts[device]['mounts'].append(mountpoint)
|
|
280
|
+
except (FileNotFoundError, PermissionError, Exception):
|
|
281
|
+
pass
|
|
282
|
+
return mounts
|
|
283
|
+
|
|
284
|
+
def _is_partition(self, name):
|
|
285
|
+
"""Check if device is a partition (non-blocking).
|
|
286
|
+
|
|
287
|
+
Returns True if /sys/class/block/{name}/partition exists.
|
|
288
|
+
"""
|
|
289
|
+
return os.path.exists(f'/sys/class/block/{name}/partition')
|
|
290
|
+
|
|
291
|
+
def _get_parent_from_sysfs(self, name):
|
|
292
|
+
"""Find parent disk for a partition from sysfs (non-blocking).
|
|
293
|
+
|
|
294
|
+
For sda1, follows /sys/class/block/sda1 symlink and extracts parent.
|
|
295
|
+
Returns None for whole disks.
|
|
296
|
+
"""
|
|
297
|
+
if not self._is_partition(name):
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
# The sysfs symlink for a partition points to a path containing the parent
|
|
302
|
+
# e.g., /sys/class/block/sda1 -> ../../devices/.../sda/sda1
|
|
303
|
+
real_path = os.path.realpath(f'/sys/class/block/{name}')
|
|
304
|
+
parent_path = os.path.dirname(real_path)
|
|
305
|
+
parent_name = os.path.basename(parent_path)
|
|
306
|
+
|
|
307
|
+
# Verify it's actually a disk (not some intermediate directory)
|
|
308
|
+
if os.path.exists(f'/sys/class/block/{parent_name}'):
|
|
309
|
+
return parent_name
|
|
310
|
+
except (OSError, Exception):
|
|
311
|
+
pass
|
|
312
|
+
|
|
313
|
+
# Fallback: strip numeric suffix (sda1 -> sda, nvme0n1p1 -> nvme0n1)
|
|
314
|
+
if name.startswith('nvme') and 'p' in name:
|
|
315
|
+
return name.rsplit('p', 1)[0]
|
|
316
|
+
elif name.startswith('mmcblk') and 'p' in name:
|
|
317
|
+
return name.rsplit('p', 1)[0]
|
|
318
|
+
else:
|
|
319
|
+
# SATA/USB: strip trailing digits
|
|
320
|
+
return name.rstrip('0123456789') or None
|
|
321
|
+
|
|
322
|
+
def _get_size_from_sysfs(self, name):
|
|
323
|
+
"""Get device size in bytes from sysfs (non-blocking).
|
|
324
|
+
|
|
325
|
+
Reads /sys/class/block/{name}/size which contains sector count.
|
|
326
|
+
"""
|
|
327
|
+
try:
|
|
328
|
+
with open(f'/sys/class/block/{name}/size', 'r', encoding='utf-8') as f:
|
|
329
|
+
sectors = int(f.read().strip())
|
|
330
|
+
return sectors * 512 # Convert to bytes
|
|
331
|
+
except (FileNotFoundError, ValueError, Exception):
|
|
332
|
+
return 0
|
|
333
|
+
|
|
334
|
+
def _get_serial_from_sysfs(self, name):
|
|
335
|
+
"""Get device serial number from sysfs (non-blocking).
|
|
336
|
+
|
|
337
|
+
NVMe: /sys/class/block/{name}/device/serial (plain text)
|
|
338
|
+
SATA: /sys/class/block/{name}/device/vpd_pg80 (binary VPD page)
|
|
339
|
+
"""
|
|
340
|
+
# Try NVMe-style path first (plain text)
|
|
341
|
+
try:
|
|
342
|
+
with open(f'/sys/class/block/{name}/device/serial', 'r', encoding='utf-8') as f:
|
|
343
|
+
# Sanitize: some USB bridges return strings with embedded nulls
|
|
344
|
+
return f.read().strip().replace('\x00', '')
|
|
345
|
+
except (FileNotFoundError, Exception):
|
|
346
|
+
pass
|
|
347
|
+
|
|
348
|
+
# Try SATA VPD page 80 (binary format)
|
|
349
|
+
try:
|
|
350
|
+
with open(f'/sys/class/block/{name}/device/vpd_pg80', 'rb') as f:
|
|
351
|
+
data = f.read()
|
|
352
|
+
if len(data) > 4:
|
|
353
|
+
# VPD format: 00 80 00 LL [serial...] where LL is length
|
|
354
|
+
length = data[3] if len(data) > 3 else 0
|
|
355
|
+
# Sanitize: remove nulls that some USB bridges include
|
|
356
|
+
serial = data[4:4+length].decode('ascii', errors='ignore').strip().replace('\x00', '')
|
|
357
|
+
return serial
|
|
358
|
+
except (FileNotFoundError, Exception):
|
|
359
|
+
pass
|
|
360
|
+
|
|
361
|
+
return ''
|
|
362
|
+
|
|
363
|
+
def _probe_blkid(self, device_name, timeout=2.0):
|
|
364
|
+
"""Get fstype, label, uuid from udev cache (instant, no subprocess).
|
|
365
|
+
|
|
366
|
+
Reads from /run/udev/data/b<major>:<minor> which is populated by udevd
|
|
367
|
+
at boot time. Falls back to empty values if cache is unavailable.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
device_name: Device name (e.g., 'sda1')
|
|
371
|
+
timeout: Unused, kept for API compatibility
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
dict with keys: fstype, label, uuid (empty strings if not found)
|
|
375
|
+
"""
|
|
376
|
+
result = {'fstype': '', 'label': '', 'uuid': ''}
|
|
377
|
+
try:
|
|
378
|
+
# Get major:minor from sysfs
|
|
379
|
+
with open(f'/sys/class/block/{device_name}/dev') as f:
|
|
380
|
+
major_minor = f.read().strip()
|
|
381
|
+
|
|
382
|
+
# Read udev data file
|
|
383
|
+
with open(f'/run/udev/data/b{major_minor}') as f:
|
|
384
|
+
for line in f:
|
|
385
|
+
if line.startswith('E:ID_FS_TYPE='):
|
|
386
|
+
result['fstype'] = line.split('=', 1)[1].strip()
|
|
387
|
+
elif line.startswith('E:ID_FS_LABEL='):
|
|
388
|
+
result['label'] = line.split('=', 1)[1].strip()
|
|
389
|
+
elif line.startswith('E:ID_PART_ENTRY_NAME=') and not result['label']:
|
|
390
|
+
raw_label = line.split('=', 1)[1].strip()
|
|
391
|
+
result['label'] = self.clean_partition_label(raw_label)
|
|
392
|
+
elif line.startswith('E:ID_FS_UUID='):
|
|
393
|
+
result['uuid'] = line.split('=', 1)[1].strip()
|
|
394
|
+
elif line.startswith('E:ID_PART_ENTRY_UUID=') and not result['uuid']:
|
|
395
|
+
result['uuid'] = line.split('=', 1)[1].strip()
|
|
396
|
+
except (FileNotFoundError, IOError, OSError):
|
|
397
|
+
pass
|
|
398
|
+
return result
|
|
399
|
+
|
|
400
|
+
def _build_dark_device_set(self, prev_nss):
|
|
401
|
+
"""Build set of device names that should not be probed (have active jobs).
|
|
402
|
+
|
|
403
|
+
A device is "dark" if:
|
|
404
|
+
- It has an active job (ns.job is not None)
|
|
405
|
+
- Its parent disk has an active job
|
|
406
|
+
- Any of its child partitions has an active job
|
|
407
|
+
|
|
408
|
+
Args:
|
|
409
|
+
prev_nss: Previous device namespaces
|
|
410
|
+
|
|
411
|
+
Returns:
|
|
412
|
+
set: Device names to skip probing
|
|
413
|
+
"""
|
|
414
|
+
dark = set()
|
|
415
|
+
if not prev_nss:
|
|
416
|
+
return dark
|
|
417
|
+
|
|
418
|
+
for name, ns in prev_nss.items():
|
|
419
|
+
if ns.job:
|
|
420
|
+
dark.add(name)
|
|
421
|
+
# Also mark parent as dark
|
|
422
|
+
if ns.parent:
|
|
423
|
+
dark.add(ns.parent)
|
|
424
|
+
# Also mark all children as dark
|
|
425
|
+
for minor in getattr(ns, 'minors', []):
|
|
426
|
+
dark.add(minor)
|
|
427
|
+
|
|
428
|
+
return dark
|
|
429
|
+
|
|
430
|
+
def discover_devices(self, dflt, prev_nss=None):
|
|
431
|
+
"""Discover devices via /proc and /sys (non-blocking replacement for lsblk).
|
|
432
|
+
|
|
433
|
+
This method replaces parse_lsblk() to avoid blocking on devices with
|
|
434
|
+
active firmware wipe jobs. All /proc and /sys reads are non-blocking.
|
|
435
|
+
Only blkid (for fstype/label/uuid) may briefly block, and it's skipped
|
|
436
|
+
for "dark" devices (those with active jobs).
|
|
157
437
|
|
|
158
438
|
Args:
|
|
159
439
|
dflt: Default state for new devices
|
|
160
440
|
prev_nss: Previous device namespaces for merging
|
|
161
|
-
|
|
162
|
-
|
|
441
|
+
|
|
442
|
+
Returns:
|
|
443
|
+
dict: Mapping of device name to SimpleNamespace
|
|
163
444
|
"""
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
entry.name = device.get('name', '')
|
|
167
|
-
maj_min = device.get('maj:min', (-1, -1))
|
|
168
|
-
wds = maj_min.split(':', maxsplit=1)
|
|
169
|
-
entry.major = -1
|
|
170
|
-
if len(wds) > 0:
|
|
171
|
-
entry.major = int(wds[0])
|
|
172
|
-
entry.fstype = device.get('fstype', '')
|
|
173
|
-
if entry.fstype is None:
|
|
174
|
-
entry.fstype = ''
|
|
175
|
-
entry.type = device.get('type', '')
|
|
176
|
-
entry.label = device.get('label', '')
|
|
177
|
-
if not entry.label:
|
|
178
|
-
entry.label = device.get('partlabel', '')
|
|
179
|
-
if entry.label is None:
|
|
180
|
-
entry.label = ''
|
|
181
|
-
entry.size_bytes = int(device.get('size', 0))
|
|
182
|
-
|
|
183
|
-
# Get UUID - prefer PARTUUID for partitions, UUID for filesystems
|
|
184
|
-
entry.uuid = device.get('partuuid', '') or device.get('uuid', '') or ''
|
|
185
|
-
entry.serial = device.get('serial', '') or ''
|
|
186
|
-
|
|
187
|
-
mounts = device.get('mountpoints', [])
|
|
188
|
-
while len(mounts) >= 1 and mounts[0] is None:
|
|
189
|
-
del mounts[0]
|
|
190
|
-
entry.mounts = mounts
|
|
191
|
-
|
|
192
|
-
# Check if we should read the marker (3-state model: dont-know, got-marker, no-marker)
|
|
193
|
-
# Read marker ONCE when:
|
|
194
|
-
# 1. Not mounted
|
|
195
|
-
# 2. No filesystem (fstype/label empty)
|
|
196
|
-
# 3. No active job
|
|
197
|
-
# 4. Haven't checked yet (marker_checked=False)
|
|
198
|
-
has_job = prev_nss and entry.name in prev_nss and getattr(prev_nss[entry.name], 'job', None) is not None
|
|
199
|
-
has_filesystem = entry.fstype or entry.label
|
|
445
|
+
# Build dark device set from previous state
|
|
446
|
+
dark_devices = self._build_dark_device_set(prev_nss)
|
|
200
447
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
if should_read_marker:
|
|
214
|
-
entry.marker_checked = True # Mark as checked regardless of result
|
|
215
|
-
marker = WipeJob.read_marker_buffer(entry.name)
|
|
216
|
-
now = int(round(time.time()))
|
|
217
|
-
if (marker and marker.size_bytes == entry.size_bytes
|
|
218
|
-
and marker.unixtime < now):
|
|
219
|
-
# For multi-pass wipes, scrubbed_bytes can exceed size_bytes
|
|
220
|
-
# Calculate completion percentage (capped at 100%)
|
|
221
|
-
pct = min(100, int(round((marker.scrubbed_bytes / marker.size_bytes) * 100)))
|
|
222
|
-
state = 'W' if pct >= 100 else 's'
|
|
223
|
-
dt = datetime.datetime.fromtimestamp(marker.unixtime)
|
|
224
|
-
# Add verification status prefix
|
|
225
|
-
verify_prefix = ''
|
|
226
|
-
verify_status = getattr(marker, 'verify_status', None)
|
|
227
|
-
if verify_status == 'pass':
|
|
228
|
-
verify_prefix = '✓ '
|
|
229
|
-
elif verify_status == 'fail':
|
|
230
|
-
verify_prefix = '✗ '
|
|
231
|
-
|
|
232
|
-
# Add error suffix if job failed abnormally
|
|
233
|
-
error_suffix = ''
|
|
234
|
-
abort_reason = getattr(marker, 'abort_reason', None)
|
|
235
|
-
if abort_reason:
|
|
236
|
-
error_suffix = f' Err[{abort_reason}]'
|
|
237
|
-
|
|
238
|
-
entry.marker = f'{verify_prefix}{state} {pct}% {marker.mode} {dt.strftime("%Y/%m/%d %H:%M")}{error_suffix}'
|
|
239
|
-
entry.state = state
|
|
240
|
-
entry.dflt = state # Set dflt so merge logic knows this partition has a marker
|
|
241
|
-
|
|
242
|
-
return entry
|
|
243
|
-
|
|
244
|
-
# Get lsblk output - either from parameter or by running command
|
|
245
|
-
if lsblk_output: # Non-empty string from background monitor
|
|
246
|
-
# Use provided output string
|
|
247
|
-
try:
|
|
248
|
-
parsed_data = json.loads(lsblk_output)
|
|
249
|
-
except (json.JSONDecodeError, Exception):
|
|
250
|
-
# Invalid JSON - return empty dict
|
|
251
|
-
return {}
|
|
252
|
-
else:
|
|
253
|
-
# Run the `lsblk` command and get its output in JSON format with additional columns
|
|
254
|
-
# Use timeout to prevent UI freeze if lsblk hangs on problematic devices
|
|
255
|
-
try:
|
|
256
|
-
result = subprocess.run(['lsblk', '-J', '--bytes', '-o',
|
|
257
|
-
'NAME,MAJ:MIN,FSTYPE,TYPE,LABEL,PARTLABEL,FSUSE%,SIZE,MOUNTPOINTS,UUID,PARTUUID,SERIAL'],
|
|
258
|
-
stdout=subprocess.PIPE, text=True, check=False, timeout=10.0)
|
|
259
|
-
parsed_data = json.loads(result.stdout)
|
|
260
|
-
except (subprocess.TimeoutExpired, json.JSONDecodeError, Exception): # pylint: disable=broad-exception-caught
|
|
261
|
-
# lsblk hung, returned bad JSON, or other error - return empty dict
|
|
262
|
-
# assemble_partitions will detect this and preserve previous state
|
|
263
|
-
return {}
|
|
448
|
+
# Increment discovery cycle counter (proves code is running)
|
|
449
|
+
DeviceInfo._discovery_cycle += 1
|
|
450
|
+
|
|
451
|
+
# Phase 1: Parse /proc for basic device list and mounts
|
|
452
|
+
proc_devices = self._parse_proc_partitions()
|
|
453
|
+
if not proc_devices:
|
|
454
|
+
return {} # Critical failure - let caller handle
|
|
455
|
+
|
|
456
|
+
mounts = self._parse_proc_mounts()
|
|
457
|
+
|
|
458
|
+
# Phase 2: Build device entries from sysfs
|
|
264
459
|
entries = {}
|
|
460
|
+
parent_map = {} # name -> parent_name for building minors lists later
|
|
461
|
+
|
|
462
|
+
for name, proc_info in proc_devices.items():
|
|
463
|
+
# Get size from sysfs (more accurate than /proc/partitions blocks)
|
|
464
|
+
size_bytes = self._get_size_from_sysfs(name)
|
|
465
|
+
if size_bytes == 0:
|
|
466
|
+
size_bytes = proc_info.blocks * 1024 # Fallback to /proc/partitions
|
|
467
|
+
|
|
468
|
+
entry = self._make_partition_namespace(proc_info.major, name, size_bytes, dflt)
|
|
469
|
+
mount_info = mounts.get(name, {})
|
|
470
|
+
entry.mounts = mount_info.get('mounts', [])
|
|
471
|
+
if entry.mounts:
|
|
472
|
+
entry.fstype = mount_info.get('fstype', '')
|
|
473
|
+
|
|
474
|
+
# Determine if partition or disk
|
|
475
|
+
if self._is_partition(name):
|
|
476
|
+
entry.type = 'part'
|
|
477
|
+
parent_name = self._get_parent_from_sysfs(name)
|
|
478
|
+
if parent_name:
|
|
479
|
+
entry.parent = parent_name
|
|
480
|
+
parent_map[name] = parent_name
|
|
481
|
+
else:
|
|
482
|
+
entry.type = 'disk'
|
|
483
|
+
|
|
484
|
+
# Set mount state
|
|
485
|
+
if entry.mounts:
|
|
486
|
+
entry.state = 'Mnt'
|
|
487
|
+
|
|
488
|
+
# Check if this is a dark device
|
|
489
|
+
is_dark = name in dark_devices
|
|
490
|
+
|
|
491
|
+
# Phase 3: Conditional probing (skip for dark devices)
|
|
492
|
+
if is_dark and prev_nss and name in prev_nss:
|
|
493
|
+
# Carry forward all data from previous state
|
|
494
|
+
prev = prev_nss[name]
|
|
495
|
+
entry.fstype = prev.fstype
|
|
496
|
+
entry.label = prev.label
|
|
497
|
+
entry.uuid = prev.uuid
|
|
498
|
+
entry.serial = prev.serial
|
|
499
|
+
entry.marker = prev.marker
|
|
500
|
+
entry.hw_caps = getattr(prev, 'hw_caps', '')
|
|
501
|
+
entry.hw_caps_summary = getattr(prev, 'hw_caps_summary', '')
|
|
502
|
+
entry.hw_nopes = getattr(prev, 'hw_nopes', '')
|
|
503
|
+
entry.hw_caps_state = getattr(prev, 'hw_caps_state', ProbeState.PENDING)
|
|
504
|
+
entry.model = getattr(prev, 'model', '')
|
|
505
|
+
entry.port = getattr(prev, 'port', '')
|
|
506
|
+
entry.is_rotational = getattr(prev, 'is_rotational', False)
|
|
507
|
+
else:
|
|
508
|
+
# Non-dark device: probe for metadata
|
|
509
|
+
if entry.type == 'disk':
|
|
510
|
+
# Disk-level info from sysfs
|
|
511
|
+
entry.serial = self._get_serial_from_sysfs(name)
|
|
512
|
+
entry.model = self._get_device_vendor_model(name)
|
|
513
|
+
entry.port = self._get_port_from_sysfs(name)
|
|
514
|
+
entry.is_rotational = self._is_rotational_device(name)
|
|
515
|
+
|
|
516
|
+
# Get fstype/label/uuid via blkid (skip only if dark device)
|
|
517
|
+
if not is_dark:
|
|
518
|
+
blkid_info = self._probe_blkid(name)
|
|
519
|
+
# If not mounted, use blkid fstype; if mounted, use mount fstype
|
|
520
|
+
if not entry.mounts:
|
|
521
|
+
entry.fstype = blkid_info['fstype']
|
|
522
|
+
# Always get label and uuid from blkid
|
|
523
|
+
entry.label = blkid_info['label']
|
|
524
|
+
entry.uuid = blkid_info['uuid']
|
|
525
|
+
|
|
526
|
+
# Marker monitoring: ALWAYS update, even for dark devices
|
|
527
|
+
# This ensures markers are detected immediately after wipes
|
|
528
|
+
has_job = is_dark # Already checked above
|
|
529
|
+
has_filesystem = entry.fstype or entry.label
|
|
265
530
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
531
|
+
# Check if device has actual partitions even before we build minors list
|
|
532
|
+
has_child_partitions = any(parent_name == name for parent_name in parent_map.values())
|
|
533
|
+
|
|
534
|
+
# Marker state is now entirely managed by worker thread
|
|
535
|
+
# (no marker_checked, monitor_marker, etc. fields anymore)
|
|
536
|
+
|
|
537
|
+
# Determine if we should ask worker to monitor for marker
|
|
538
|
+
# For whole disks: no job, no children, not mounted
|
|
539
|
+
# For partitions: no job, no filesystem, not mounted
|
|
540
|
+
if entry.type == 'disk':
|
|
541
|
+
should_want_marker = (not has_job and not has_child_partitions and
|
|
542
|
+
not entry.mounts)
|
|
543
|
+
elif entry.type == 'part':
|
|
544
|
+
should_want_marker = (not has_job and not has_filesystem and
|
|
545
|
+
not entry.mounts)
|
|
546
|
+
else:
|
|
547
|
+
should_want_marker = False
|
|
548
|
+
|
|
549
|
+
# Tell worker to monitor or stop monitoring
|
|
550
|
+
if self.worker_manager:
|
|
551
|
+
self.worker_manager.set_want_marker(name, should_want_marker)
|
|
552
|
+
# Request immediate marker check for faster feedback
|
|
553
|
+
if should_want_marker:
|
|
554
|
+
self.worker_manager.request_marker_check(name)
|
|
555
|
+
|
|
556
|
+
# Get the formatted marker string from worker (if any)
|
|
557
|
+
entry.want_marker = should_want_marker
|
|
558
|
+
if should_want_marker:
|
|
559
|
+
entry.marker = self.worker_manager.get_marker_formatted(name)
|
|
560
|
+
# Extract state from marker string format: "{prefix}{state} {pct}%"
|
|
561
|
+
# State is 'W' (wiped) or 's' (scrubbing), possibly with prefix (✓ or ✗)
|
|
562
|
+
if entry.marker:
|
|
563
|
+
if 'W ' in entry.marker:
|
|
564
|
+
entry.state = 'W'
|
|
565
|
+
entry.dflt = 'W'
|
|
566
|
+
elif 's ' in entry.marker:
|
|
567
|
+
entry.state = 's'
|
|
568
|
+
entry.dflt = 's'
|
|
569
|
+
else:
|
|
570
|
+
# No marker found, reset state to default (not wiped/scrubbing)
|
|
571
|
+
entry.state = '-'
|
|
572
|
+
entry.dflt = '-'
|
|
573
|
+
else:
|
|
574
|
+
entry.marker = ''
|
|
575
|
+
# Not monitoring, reset to default state
|
|
576
|
+
entry.state = '-'
|
|
577
|
+
entry.dflt = '-'
|
|
578
|
+
|
|
579
|
+
entries[name] = entry
|
|
580
|
+
|
|
581
|
+
# Phase 4: Build parent-child relationships (minors lists)
|
|
582
|
+
for name, parent_name in parent_map.items():
|
|
583
|
+
if parent_name in entries:
|
|
584
|
+
entries[parent_name].minors.append(name)
|
|
585
|
+
self.disk_majors.add(entries[name].major)
|
|
586
|
+
# Propagate mount state to parent
|
|
587
|
+
if entries[name].mounts:
|
|
588
|
+
entries[parent_name].state = 'iMnt'
|
|
589
|
+
|
|
590
|
+
# Phase 4b: Clear marker display and state for disks with actual partitions
|
|
591
|
+
# If a disk has partitions, the partitions take precedence over the wipe marker
|
|
592
|
+
for name, entry in entries.items():
|
|
593
|
+
if entry.minors: # Disk has child partitions
|
|
594
|
+
entry.marker = '' # Don't show marker when partitions exist
|
|
595
|
+
# Also clear the state if it was set to a wipe state ('W' or 's')
|
|
596
|
+
# Reset to default state '-' (unmarked)
|
|
597
|
+
if entry.state in ('W', 's'):
|
|
598
|
+
entry.state = '-'
|
|
599
|
+
if entry.dflt in ('W', 's'):
|
|
600
|
+
entry.dflt = '-'
|
|
601
|
+
|
|
602
|
+
# Phase 5: Handle superfloppy case and clean disk rows
|
|
282
603
|
final_entries = {}
|
|
283
604
|
for name, entry in entries.items():
|
|
284
605
|
final_entries[name] = entry
|
|
285
606
|
|
|
286
|
-
# Only process top-level
|
|
287
|
-
if entry.parent is None:
|
|
288
|
-
#
|
|
289
|
-
entry.model = self._get_device_vendor_model(entry.name)
|
|
290
|
-
entry.port = self._get_port_from_sysfs(entry.name)
|
|
291
|
-
|
|
292
|
-
# The Split (Superfloppy Case)
|
|
293
|
-
# If it has children, the children already hold the data.
|
|
294
|
-
# If it has NO children but HAS data, we create the '----' child.
|
|
607
|
+
# Only process top-level disks
|
|
608
|
+
if entry.parent is None and entry.type == 'disk':
|
|
609
|
+
# Superfloppy: disk with filesystem but no partitions
|
|
295
610
|
if not entry.minors and (entry.fstype or entry.label or entry.mounts):
|
|
296
611
|
v_key = f"{name}_data"
|
|
297
612
|
v_child = self._make_partition_namespace(entry.major, name, entry.size_bytes, dflt)
|
|
298
613
|
v_child.name = "----"
|
|
614
|
+
v_child.type = 'part'
|
|
299
615
|
v_child.fstype = entry.fstype
|
|
300
616
|
v_child.label = entry.label
|
|
301
617
|
v_child.mounts = entry.mounts
|
|
302
618
|
v_child.parent = name
|
|
619
|
+
v_child.uuid = entry.uuid
|
|
303
620
|
|
|
304
621
|
final_entries[v_key] = v_child
|
|
305
622
|
entry.minors.append(v_key)
|
|
306
623
|
|
|
307
|
-
# Clean the
|
|
624
|
+
# Clean the disk row: show model instead of fstype
|
|
308
625
|
entry.fstype = entry.model if entry.model else 'DISK'
|
|
309
626
|
entry.label = ''
|
|
310
627
|
entry.mounts = []
|
|
311
628
|
|
|
312
|
-
|
|
629
|
+
return final_entries
|
|
313
630
|
|
|
314
|
-
return entries
|
|
315
631
|
|
|
316
632
|
@staticmethod
|
|
317
633
|
def set_one_state(nss, ns, to=None, test_to=None):
|
|
318
634
|
"""Optionally, update a state, and always set inferred states"""
|
|
319
635
|
ready_states = ('s', 'W', '-', '^')
|
|
320
636
|
job_states = ('*%', 'STOP')
|
|
321
|
-
inferred_states = ('Busy', 'Mnt',)
|
|
322
637
|
|
|
323
638
|
def state_in(to, states):
|
|
324
639
|
return to in states or fnmatch(to, states[0])
|
|
@@ -335,7 +650,7 @@ class DeviceInfo:
|
|
|
335
650
|
|
|
336
651
|
if to == 'STOP' and not state_in(ns.state, job_states):
|
|
337
652
|
return False
|
|
338
|
-
if to == 'Blk' and not state_in(ns.state, list(ready_states) + ['Mnt']):
|
|
653
|
+
if to == 'Blk' and not state_in(ns.state, list(ready_states) + ['Mnt', 'iMnt', 'iBlk']):
|
|
339
654
|
return False
|
|
340
655
|
if to == 'Unbl' and ns.state != 'Blk':
|
|
341
656
|
return False
|
|
@@ -356,9 +671,14 @@ class DeviceInfo:
|
|
|
356
671
|
|
|
357
672
|
# Here we set inferences that block starting jobs
|
|
358
673
|
# -- clearing these states will be done on the device refresh
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
674
|
+
# Propagate Mnt/iMnt from child to parent as iMnt (inherited mount)
|
|
675
|
+
if parent and ns.state in ('Mnt', 'iMnt'):
|
|
676
|
+
if parent.state not in ('Blk', 'iBlk', 'Mnt'):
|
|
677
|
+
parent.state = 'iMnt'
|
|
678
|
+
# Propagate Blk/iBlk from child to parent as iBlk (inherited block)
|
|
679
|
+
if parent and ns.state in ('Blk', 'iBlk'):
|
|
680
|
+
if parent.state != 'Blk': # Direct Blk trumps inherited
|
|
681
|
+
parent.state = 'iBlk'
|
|
362
682
|
if state_in(ns.state, job_states):
|
|
363
683
|
if parent:
|
|
364
684
|
parent.state = 'Busy'
|
|
@@ -368,8 +688,13 @@ class DeviceInfo:
|
|
|
368
688
|
|
|
369
689
|
@staticmethod
|
|
370
690
|
def clear_inferred_states(nss):
|
|
371
|
-
"""Clear all inferred states
|
|
372
|
-
|
|
691
|
+
"""Clear all inferred states so they can be re-inferred.
|
|
692
|
+
|
|
693
|
+
Inherited states (iBlk, iMnt, Busy) are cleared.
|
|
694
|
+
Direct states (Blk, Mnt) are preserved - they're set based on
|
|
695
|
+
persistent state or actual mounts, not inheritance.
|
|
696
|
+
"""
|
|
697
|
+
inferred_states = ('Busy', 'iMnt', 'iBlk')
|
|
373
698
|
for ns in nss.values():
|
|
374
699
|
if ns.state in inferred_states:
|
|
375
700
|
ns.state = ns.dflt
|
|
@@ -411,10 +736,10 @@ class DeviceInfo:
|
|
|
411
736
|
# If we can't read ro flag, skip this device to be safe
|
|
412
737
|
continue
|
|
413
738
|
|
|
414
|
-
# Exclude common virtual device prefixes as a safety net
|
|
739
|
+
# Exclude common virtual and optical device prefixes as a safety net
|
|
415
740
|
# (most should already be filtered by ro check or missing sysfs)
|
|
416
|
-
|
|
417
|
-
if any(name.startswith(prefix) for prefix in
|
|
741
|
+
excluded_prefixes = ('zram', 'loop', 'dm-', 'ram', 'sr', 'scd')
|
|
742
|
+
if any(name.startswith(prefix) for prefix in excluded_prefixes):
|
|
418
743
|
continue
|
|
419
744
|
|
|
420
745
|
# Include this device
|
|
@@ -484,8 +809,8 @@ class DeviceInfo:
|
|
|
484
809
|
# Last partition of disk: rounded corner
|
|
485
810
|
prefix = '└ '
|
|
486
811
|
else:
|
|
487
|
-
#
|
|
488
|
-
prefix = '
|
|
812
|
+
# Non-last partition: tee junction
|
|
813
|
+
prefix = '├ '
|
|
489
814
|
|
|
490
815
|
name_str = prefix + ns.name
|
|
491
816
|
|
|
@@ -493,17 +818,69 @@ class DeviceInfo:
|
|
|
493
818
|
emit += f'{sep}{Utils.human(ns.size_bytes):>{wids.human}}'
|
|
494
819
|
emit += sep + print_str_or_dash(ns.fstype, wids.fstype)
|
|
495
820
|
if ns.parent is None:
|
|
496
|
-
# Physical disk -
|
|
497
|
-
|
|
498
|
-
|
|
821
|
+
# Physical disk - show firmware capability/status centered in LABEL field
|
|
822
|
+
hw_caps = getattr(ns, 'hw_caps', '')
|
|
823
|
+
hw_nopes = getattr(ns, 'hw_nopes', '')
|
|
824
|
+
hw_summary = getattr(ns, 'hw_caps_summary', '')
|
|
825
|
+
hw_state = getattr(ns, 'hw_caps_state', ProbeState.PENDING)
|
|
826
|
+
is_usb = getattr(ns, 'is_usb', False)
|
|
827
|
+
|
|
828
|
+
fw_label = ''
|
|
829
|
+
if hw_caps:
|
|
830
|
+
fw_label = hw_summary
|
|
831
|
+
elif hw_nopes and not is_usb:
|
|
832
|
+
first_issue = hw_nopes.split(',')[0].strip()
|
|
833
|
+
fw_label = f'✗{first_issue}'
|
|
834
|
+
elif hw_state in (ProbeState.PENDING, ProbeState.PROBING):
|
|
835
|
+
if ns.state not in ('Mnt', 'iMnt', 'Blk', 'iBlk', 'Busy') and not is_usb:
|
|
836
|
+
fw_label = '...'
|
|
837
|
+
|
|
838
|
+
if not fw_label:
|
|
839
|
+
fw_label = '---'
|
|
840
|
+
|
|
841
|
+
# Split fw_label into symbol prefix and alphanumeric text for underline rendering
|
|
842
|
+
fw_symbol = ''
|
|
843
|
+
fw_text = fw_label
|
|
844
|
+
if fw_label not in ('---', '...'):
|
|
845
|
+
for i, ch in enumerate(fw_label):
|
|
846
|
+
if ch.isascii() and ch.isalnum():
|
|
847
|
+
fw_symbol = fw_label[:i]
|
|
848
|
+
fw_text = fw_label[i:]
|
|
849
|
+
break
|
|
850
|
+
|
|
851
|
+
# Record positions so caller can underline just the alphanumeric text
|
|
852
|
+
label_start = len(emit) + len(sep)
|
|
853
|
+
centered = f'{fw_label:^{wids.label}}'
|
|
854
|
+
emit += sep + centered
|
|
855
|
+
|
|
856
|
+
if fw_symbol:
|
|
857
|
+
left_pad = (wids.label - len(fw_label)) // 2
|
|
858
|
+
ul_start = label_start + left_pad + len(fw_symbol)
|
|
859
|
+
ul_end = ul_start + len(fw_text)
|
|
860
|
+
ns._fw_underline = (ul_start, ul_end)
|
|
861
|
+
else:
|
|
862
|
+
ns._fw_underline = None
|
|
863
|
+
|
|
864
|
+
# Check for aggregated mounts (from hidden children when disk is blocked)
|
|
865
|
+
agg_mounts = getattr(ns, 'aggregated_mounts', None)
|
|
866
|
+
if agg_mounts:
|
|
867
|
+
# Show first mount + count of others (e.g., "/ + 5 more")
|
|
868
|
+
first_mount = agg_mounts[0]
|
|
869
|
+
remaining = len(agg_mounts) - 1
|
|
870
|
+
if remaining > 0:
|
|
871
|
+
emit += f'{sep}{first_mount} + {remaining} more'
|
|
872
|
+
else:
|
|
873
|
+
emit += f'{sep}{first_mount}'
|
|
874
|
+
elif ns.mounts:
|
|
499
875
|
# Disk has mounts - show them
|
|
500
876
|
emit += f'{sep}{",".join(ns.mounts)}'
|
|
501
|
-
elif ns.marker and ns.marker.strip():
|
|
502
|
-
# Disk has wipe status - show it
|
|
877
|
+
elif ns.marker and ns.marker.strip() and not ns.minors:
|
|
878
|
+
# Disk has wipe status - show it ONLY if no child partitions
|
|
879
|
+
# (partitions take precedence over marker)
|
|
503
880
|
emit += f'{sep}{ns.marker}'
|
|
504
881
|
else:
|
|
505
|
-
# No status
|
|
506
|
-
emit += '
|
|
882
|
+
# No wipe status
|
|
883
|
+
emit += f'{sep} ---'
|
|
507
884
|
else:
|
|
508
885
|
# Partition: show label and mount/status info
|
|
509
886
|
emit += sep + print_str_or_dash(ns.label, wids.label)
|
|
@@ -517,7 +894,7 @@ class DeviceInfo:
|
|
|
517
894
|
# Check for newly inserted flag first (hot-swapped devices should always show orange)
|
|
518
895
|
if getattr(ns, 'newly_inserted', False):
|
|
519
896
|
# Newly inserted device - orange/bright
|
|
520
|
-
if ns.state in ('Mnt', 'Blk'):
|
|
897
|
+
if ns.state in ('Mnt', 'iMnt', 'Blk', 'iBlk'):
|
|
521
898
|
# Dim the orange for mounted/blocked devices
|
|
522
899
|
attr = curses.color_pair(Theme.HOTSWAP) | curses.A_DIM
|
|
523
900
|
else:
|
|
@@ -531,13 +908,13 @@ class DeviceInfo:
|
|
|
531
908
|
elif ns.state == 'W':
|
|
532
909
|
# Green/success color for completed wipes before this session
|
|
533
910
|
attr = curses.color_pair(Theme.OLD_SUCCESS) | curses.A_BOLD
|
|
534
|
-
elif ns.state.endswith('%') and ns.state not in ('
|
|
911
|
+
elif ns.state.endswith('%') and ns.state not in ('100%',):
|
|
535
912
|
# Active wipe in progress - bright cyan/blue with bold
|
|
536
913
|
attr = curses.color_pair(Theme.INFO) | curses.A_BOLD
|
|
537
914
|
elif ns.state == '^':
|
|
538
915
|
# Newly inserted device (hot-swapped) - orange/bright
|
|
539
916
|
attr = curses.color_pair(Theme.HOTSWAP) | curses.A_BOLD
|
|
540
|
-
elif ns.state in ('Mnt', 'Blk'):
|
|
917
|
+
elif ns.state in ('Mnt', 'iMnt', 'Blk', 'iBlk'):
|
|
541
918
|
# Dim mounted or blocked devices
|
|
542
919
|
attr = curses.A_DIM
|
|
543
920
|
|
|
@@ -545,6 +922,13 @@ class DeviceInfo:
|
|
|
545
922
|
if hasattr(ns, 'verify_failed_msg') and ns.verify_failed_msg:
|
|
546
923
|
attr = curses.color_pair(Theme.DANGER) | curses.A_BOLD
|
|
547
924
|
|
|
925
|
+
# Make disk lines bold
|
|
926
|
+
if ns.parent is None:
|
|
927
|
+
if attr is None:
|
|
928
|
+
attr = curses.A_BOLD
|
|
929
|
+
else:
|
|
930
|
+
attr |= curses.A_BOLD
|
|
931
|
+
|
|
548
932
|
return emit, attr
|
|
549
933
|
|
|
550
934
|
def merge_dev_infos(self, nss, prev_nss=None):
|
|
@@ -570,10 +954,15 @@ class DeviceInfo:
|
|
|
570
954
|
# Preserve the "wiped this session" flag
|
|
571
955
|
if hasattr(prev_ns, 'wiped_this_session'):
|
|
572
956
|
new_ns.wiped_this_session = prev_ns.wiped_this_session
|
|
573
|
-
#
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
957
|
+
# Marker is now handled entirely by worker thread, no preservation needed
|
|
958
|
+
|
|
959
|
+
# Preserve hw_caps state - once probed, it's permanent for the device
|
|
960
|
+
prev_hw_state = getattr(prev_ns, 'hw_caps_state', ProbeState.PENDING)
|
|
961
|
+
if prev_hw_state == ProbeState.READY:
|
|
962
|
+
new_ns.hw_caps = getattr(prev_ns, 'hw_caps', '')
|
|
963
|
+
new_ns.hw_caps_summary = getattr(prev_ns, 'hw_caps_summary', '')
|
|
964
|
+
new_ns.hw_nopes = getattr(prev_ns, 'hw_nopes', '')
|
|
965
|
+
new_ns.hw_caps_state = ProbeState.READY
|
|
577
966
|
|
|
578
967
|
# Preserve verify failure message ONLY for unmarked disks
|
|
579
968
|
# Clear if: filesystem appeared OR partition now has a marker
|
|
@@ -608,8 +997,8 @@ class DeviceInfo:
|
|
|
608
997
|
new_ns.state = 'Blk'
|
|
609
998
|
elif new_ns.state not in ('s', 'W'):
|
|
610
999
|
new_ns.state = new_ns.dflt
|
|
611
|
-
# Don't copy forward percentage states
|
|
612
|
-
if prev_ns.state not in ('s', 'W', 'Busy', 'Unbl') and not prev_ns.state.endswith('%'):
|
|
1000
|
+
# Don't copy forward percentage states or inherited states - only persistent states
|
|
1001
|
+
if prev_ns.state not in ('s', 'W', 'Busy', 'Unbl', 'iBlk', 'iMnt') and not prev_ns.state.endswith('%'):
|
|
613
1002
|
new_ns.state = prev_ns.state # re-infer these
|
|
614
1003
|
elif prev_ns.job:
|
|
615
1004
|
# unplugged device with job..
|
|
@@ -624,20 +1013,18 @@ class DeviceInfo:
|
|
|
624
1013
|
new_ns.newly_inserted = True # Mark for orange color even if locked/mounted
|
|
625
1014
|
return nss
|
|
626
1015
|
|
|
627
|
-
def assemble_partitions(self, prev_nss=None
|
|
1016
|
+
def assemble_partitions(self, prev_nss=None):
|
|
628
1017
|
"""Assemble and filter partitions for display
|
|
629
1018
|
|
|
630
1019
|
Args:
|
|
631
1020
|
prev_nss: Previous device namespaces for merging
|
|
632
|
-
lsblk_output: Optional lsblk JSON output string from LsblkMonitor
|
|
633
1021
|
"""
|
|
634
|
-
nss = self.
|
|
635
|
-
lsblk_output=lsblk_output)
|
|
1022
|
+
nss = self.discover_devices(dflt='^' if prev_nss else '-', prev_nss=prev_nss)
|
|
636
1023
|
|
|
637
|
-
# If
|
|
1024
|
+
# If discover_devices failed (returned empty) and we have previous data, keep previous state
|
|
638
1025
|
if not nss and prev_nss:
|
|
639
|
-
#
|
|
640
|
-
# This prevents losing devices when
|
|
1026
|
+
# Device scan failed or returned no devices - preserve previous state
|
|
1027
|
+
# This prevents losing devices when discovery temporarily fails
|
|
641
1028
|
# But clear temporary status messages from completed jobs
|
|
642
1029
|
for ns in prev_nss.values():
|
|
643
1030
|
if not ns.job and ns.mounts:
|
|
@@ -649,6 +1036,9 @@ class DeviceInfo:
|
|
|
649
1036
|
|
|
650
1037
|
nss = self.merge_dev_infos(nss, prev_nss)
|
|
651
1038
|
|
|
1039
|
+
# Update device workers for background probing
|
|
1040
|
+
self.worker_manager.update_devices(nss.keys())
|
|
1041
|
+
|
|
652
1042
|
# Apply persistent blocked states
|
|
653
1043
|
if self.persistent_state:
|
|
654
1044
|
for ns in nss.values():
|
|
@@ -660,7 +1050,13 @@ class DeviceInfo:
|
|
|
660
1050
|
|
|
661
1051
|
# Clear inferred states so they can be re-computed based on current job status
|
|
662
1052
|
self.clear_inferred_states(nss)
|
|
663
|
-
|
|
1053
|
+
|
|
1054
|
+
# Re-apply Mnt state for devices with mounts (cleared above, needed for set_all_states)
|
|
1055
|
+
for ns in nss.values():
|
|
1056
|
+
if ns.mounts:
|
|
1057
|
+
ns.state = 'Mnt'
|
|
1058
|
+
|
|
1059
|
+
self.set_all_states(nss) # set inferred states (propagates Mnt/Busy to parents)
|
|
664
1060
|
|
|
665
1061
|
self.compute_field_widths(nss)
|
|
666
1062
|
return nss
|
|
@@ -690,8 +1086,7 @@ class DeviceInfo:
|
|
|
690
1086
|
|
|
691
1087
|
# Hardware capabilities
|
|
692
1088
|
if disk.hw_caps:
|
|
693
|
-
|
|
694
|
-
print(f'│ Hardware: {caps}')
|
|
1089
|
+
print(f'│ Hardware: {disk.hw_caps}')
|
|
695
1090
|
|
|
696
1091
|
# Find and print partitions for this disk
|
|
697
1092
|
disk_parts = [(name, part) for name, part in partitions.items()
|