dwipe 2.0.2__py3-none-any.whl → 3.0.1__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 +592 -193
- dwipe/DeviceWorker.py +572 -0
- dwipe/DiskWipe.py +569 -136
- 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.1.dist-info}/METADATA +218 -99
- dwipe-3.0.1.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.1.dist-info}/WHEEL +0 -0
- {dwipe-2.0.2.dist-info → dwipe-3.0.1.dist-info}/entry_points.txt +0 -0
- {dwipe-2.0.2.dist-info → dwipe-3.0.1.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,55 @@ 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
|
-
ns.
|
|
115
|
+
# 2. Skip probing non-wipeable devices (mounted/blocked)
|
|
116
|
+
if ns.state in ('Mnt', 'iMnt', 'Blk', 'iBlk', 'Busy'):
|
|
117
|
+
return ns.hw_caps, ns.hw_nopes
|
|
74
118
|
|
|
75
|
-
# Skip
|
|
76
|
-
if
|
|
119
|
+
# 3. Skip probing if device has active job (would block on SATA wipe)
|
|
120
|
+
if ns.job:
|
|
77
121
|
return ns.hw_caps, ns.hw_nopes
|
|
78
122
|
|
|
79
|
-
#
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
# 5.
|
|
86
|
-
ns.hw_caps
|
|
123
|
+
# 3. Request probe from worker (non-blocking)
|
|
124
|
+
self.worker_manager.request_hw_caps(ns.name)
|
|
125
|
+
|
|
126
|
+
# 4. Get current state from worker
|
|
127
|
+
hw_caps, hw_caps_summary, hw_nopes, state, is_usb, is_rotational = self.worker_manager.get_hw_caps(ns.name)
|
|
128
|
+
|
|
129
|
+
# 5. Update namespace with worker state
|
|
130
|
+
ns.hw_caps = hw_caps
|
|
131
|
+
ns.hw_caps_summary = hw_caps_summary
|
|
132
|
+
ns.hw_nopes = hw_nopes
|
|
133
|
+
ns.hw_caps_state = state
|
|
134
|
+
ns.is_usb = is_usb
|
|
135
|
+
ns.is_rotational = is_rotational
|
|
136
|
+
|
|
87
137
|
return ns.hw_caps, ns.hw_nopes
|
|
88
138
|
|
|
89
139
|
def _get_port_from_sysfs(self, device_name):
|
|
@@ -144,7 +194,8 @@ class DeviceInfo:
|
|
|
144
194
|
rv = ''
|
|
145
195
|
fullpath = f'/sys/class/block/{device_name}/device/{suffix}'
|
|
146
196
|
with open(fullpath, 'r', encoding='utf-8') as f: # Read information
|
|
147
|
-
|
|
197
|
+
# Sanitize: some USB bridges return strings with embedded nulls
|
|
198
|
+
rv = f.read().strip().replace('\x00', '')
|
|
148
199
|
except (FileNotFoundError, Exception):
|
|
149
200
|
pass
|
|
150
201
|
return rv
|
|
@@ -152,173 +203,441 @@ class DeviceInfo:
|
|
|
152
203
|
rv = f'{get_str(device_name, "model")}'
|
|
153
204
|
return rv.strip()
|
|
154
205
|
|
|
155
|
-
|
|
156
|
-
|
|
206
|
+
@staticmethod
|
|
207
|
+
def _is_rotational_device(device_name):
|
|
208
|
+
"""Check if device is rotational (HDD) vs solid-state (SSD).
|
|
209
|
+
|
|
210
|
+
Reads /sys/block/<device>/queue/rotational:
|
|
211
|
+
- 1 = HDD (spinning disk)
|
|
212
|
+
- 0 = SSD (solid-state)
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
bool: True if HDD (rotational), False if SSD or unknown
|
|
216
|
+
"""
|
|
217
|
+
try:
|
|
218
|
+
# For partitions, get the parent disk name
|
|
219
|
+
parent = device_name.rstrip('0123456789')
|
|
220
|
+
if parent.endswith('p') and parent[:-1].rstrip('0123456789'):
|
|
221
|
+
# NVMe style: nvme0n1p1 -> nvme0n1
|
|
222
|
+
parent = parent[:-1]
|
|
223
|
+
if not parent:
|
|
224
|
+
parent = device_name
|
|
225
|
+
|
|
226
|
+
path = f'/sys/block/{parent}/queue/rotational'
|
|
227
|
+
with open(path, 'r', encoding='utf-8') as f:
|
|
228
|
+
return f.read().strip() == '1'
|
|
229
|
+
except (FileNotFoundError, PermissionError, OSError):
|
|
230
|
+
return False
|
|
231
|
+
|
|
232
|
+
# ========================================================================
|
|
233
|
+
# Non-blocking device discovery helpers (replacement for lsblk)
|
|
234
|
+
# ========================================================================
|
|
235
|
+
|
|
236
|
+
def _parse_proc_partitions(self):
|
|
237
|
+
"""Parse /proc/partitions for basic device list (non-blocking).
|
|
238
|
+
|
|
239
|
+
Format: major minor #blocks name
|
|
240
|
+
8 0 1048576 sda
|
|
241
|
+
8 1 524288 sda1
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
dict: {name: SimpleNamespace(major, minor, blocks, name)}
|
|
245
|
+
"""
|
|
246
|
+
devices = {}
|
|
247
|
+
try:
|
|
248
|
+
with open('/proc/partitions', 'r', encoding='utf-8') as f:
|
|
249
|
+
for line in f:
|
|
250
|
+
parts = line.split()
|
|
251
|
+
if len(parts) == 4 and parts[0].isdigit():
|
|
252
|
+
major, minor, blocks, name = parts
|
|
253
|
+
devices[name] = SimpleNamespace(
|
|
254
|
+
major=int(major),
|
|
255
|
+
minor=int(minor),
|
|
256
|
+
blocks=int(blocks), # In KB
|
|
257
|
+
name=name
|
|
258
|
+
)
|
|
259
|
+
except (FileNotFoundError, PermissionError, Exception):
|
|
260
|
+
pass
|
|
261
|
+
return devices
|
|
262
|
+
|
|
263
|
+
def _parse_proc_mounts(self):
|
|
264
|
+
"""Parse /proc/mounts for mount points (non-blocking).
|
|
265
|
+
|
|
266
|
+
Format: device mountpoint fstype options dump pass
|
|
267
|
+
/dev/sda1 /boot ext4 rw,relatime 0 0
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
dict: {device_name: {'mounts': [mountpoint1, ...], 'fstype': str}}
|
|
271
|
+
"""
|
|
272
|
+
mounts = {}
|
|
273
|
+
try:
|
|
274
|
+
with open('/proc/mounts', 'r', encoding='utf-8') as f:
|
|
275
|
+
for line in f:
|
|
276
|
+
parts = line.split()
|
|
277
|
+
if len(parts) >= 3 and parts[0].startswith('/dev/'):
|
|
278
|
+
device = parts[0][5:] # Strip '/dev/'
|
|
279
|
+
mountpoint = parts[1]
|
|
280
|
+
fstype = parts[2]
|
|
281
|
+
if device not in mounts:
|
|
282
|
+
mounts[device] = {'mounts': [], 'fstype': fstype}
|
|
283
|
+
mounts[device]['mounts'].append(mountpoint)
|
|
284
|
+
except (FileNotFoundError, PermissionError, Exception):
|
|
285
|
+
pass
|
|
286
|
+
return mounts
|
|
287
|
+
|
|
288
|
+
def _is_partition(self, name):
|
|
289
|
+
"""Check if device is a partition (non-blocking).
|
|
290
|
+
|
|
291
|
+
Returns True if /sys/class/block/{name}/partition exists.
|
|
292
|
+
"""
|
|
293
|
+
return os.path.exists(f'/sys/class/block/{name}/partition')
|
|
294
|
+
|
|
295
|
+
def _get_parent_from_sysfs(self, name):
|
|
296
|
+
"""Find parent disk for a partition from sysfs (non-blocking).
|
|
297
|
+
|
|
298
|
+
For sda1, follows /sys/class/block/sda1 symlink and extracts parent.
|
|
299
|
+
Returns None for whole disks.
|
|
300
|
+
"""
|
|
301
|
+
if not self._is_partition(name):
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
# The sysfs symlink for a partition points to a path containing the parent
|
|
306
|
+
# e.g., /sys/class/block/sda1 -> ../../devices/.../sda/sda1
|
|
307
|
+
real_path = os.path.realpath(f'/sys/class/block/{name}')
|
|
308
|
+
parent_path = os.path.dirname(real_path)
|
|
309
|
+
parent_name = os.path.basename(parent_path)
|
|
310
|
+
|
|
311
|
+
# Verify it's actually a disk (not some intermediate directory)
|
|
312
|
+
if os.path.exists(f'/sys/class/block/{parent_name}'):
|
|
313
|
+
return parent_name
|
|
314
|
+
except (OSError, Exception):
|
|
315
|
+
pass
|
|
316
|
+
|
|
317
|
+
# Fallback: strip numeric suffix (sda1 -> sda, nvme0n1p1 -> nvme0n1)
|
|
318
|
+
if name.startswith('nvme') and 'p' in name:
|
|
319
|
+
return name.rsplit('p', 1)[0]
|
|
320
|
+
elif name.startswith('mmcblk') and 'p' in name:
|
|
321
|
+
return name.rsplit('p', 1)[0]
|
|
322
|
+
else:
|
|
323
|
+
# SATA/USB: strip trailing digits
|
|
324
|
+
return name.rstrip('0123456789') or None
|
|
325
|
+
|
|
326
|
+
def _get_size_from_sysfs(self, name):
|
|
327
|
+
"""Get device size in bytes from sysfs (non-blocking).
|
|
328
|
+
|
|
329
|
+
Reads /sys/class/block/{name}/size which contains sector count.
|
|
330
|
+
"""
|
|
331
|
+
try:
|
|
332
|
+
with open(f'/sys/class/block/{name}/size', 'r', encoding='utf-8') as f:
|
|
333
|
+
sectors = int(f.read().strip())
|
|
334
|
+
return sectors * 512 # Convert to bytes
|
|
335
|
+
except (FileNotFoundError, ValueError, Exception):
|
|
336
|
+
return 0
|
|
337
|
+
|
|
338
|
+
def _get_serial_from_sysfs(self, name):
|
|
339
|
+
"""Get device serial number from sysfs (non-blocking).
|
|
340
|
+
|
|
341
|
+
NVMe: /sys/class/block/{name}/device/serial (plain text)
|
|
342
|
+
SATA: /sys/class/block/{name}/device/vpd_pg80 (binary VPD page)
|
|
343
|
+
"""
|
|
344
|
+
# Try NVMe-style path first (plain text)
|
|
345
|
+
try:
|
|
346
|
+
with open(f'/sys/class/block/{name}/device/serial', 'r', encoding='utf-8') as f:
|
|
347
|
+
# Sanitize: some USB bridges return strings with embedded nulls
|
|
348
|
+
return f.read().strip().replace('\x00', '')
|
|
349
|
+
except (FileNotFoundError, Exception):
|
|
350
|
+
pass
|
|
351
|
+
|
|
352
|
+
# Try SATA VPD page 80 (binary format)
|
|
353
|
+
try:
|
|
354
|
+
with open(f'/sys/class/block/{name}/device/vpd_pg80', 'rb') as f:
|
|
355
|
+
data = f.read()
|
|
356
|
+
if len(data) > 4:
|
|
357
|
+
# VPD format: 00 80 00 LL [serial...] where LL is length
|
|
358
|
+
length = data[3] if len(data) > 3 else 0
|
|
359
|
+
# Sanitize: remove nulls that some USB bridges include
|
|
360
|
+
serial = data[4:4+length].decode('ascii', errors='ignore').strip().replace('\x00', '')
|
|
361
|
+
return serial
|
|
362
|
+
except (FileNotFoundError, Exception):
|
|
363
|
+
pass
|
|
364
|
+
|
|
365
|
+
return ''
|
|
366
|
+
|
|
367
|
+
def _probe_blkid(self, device_name, timeout=2.0):
|
|
368
|
+
"""Get fstype, label, uuid from udev cache (instant, no subprocess).
|
|
369
|
+
|
|
370
|
+
Reads from /run/udev/data/b<major>:<minor> which is populated by udevd
|
|
371
|
+
at boot time. Falls back to empty values if cache is unavailable.
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
device_name: Device name (e.g., 'sda1')
|
|
375
|
+
timeout: Unused, kept for API compatibility
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
dict with keys: fstype, label, uuid (empty strings if not found)
|
|
379
|
+
"""
|
|
380
|
+
result = {'fstype': '', 'label': '', 'uuid': ''}
|
|
381
|
+
try:
|
|
382
|
+
# Get major:minor from sysfs
|
|
383
|
+
with open(f'/sys/class/block/{device_name}/dev') as f:
|
|
384
|
+
major_minor = f.read().strip()
|
|
385
|
+
|
|
386
|
+
# Read udev data file
|
|
387
|
+
with open(f'/run/udev/data/b{major_minor}') as f:
|
|
388
|
+
for line in f:
|
|
389
|
+
if line.startswith('E:ID_FS_TYPE='):
|
|
390
|
+
result['fstype'] = line.split('=', 1)[1].strip()
|
|
391
|
+
elif line.startswith('E:ID_FS_LABEL='):
|
|
392
|
+
result['label'] = line.split('=', 1)[1].strip()
|
|
393
|
+
elif line.startswith('E:ID_PART_ENTRY_NAME=') and not result['label']:
|
|
394
|
+
raw_label = line.split('=', 1)[1].strip()
|
|
395
|
+
result['label'] = self.clean_partition_label(raw_label)
|
|
396
|
+
elif line.startswith('E:ID_FS_UUID='):
|
|
397
|
+
result['uuid'] = line.split('=', 1)[1].strip()
|
|
398
|
+
elif line.startswith('E:ID_PART_ENTRY_UUID=') and not result['uuid']:
|
|
399
|
+
result['uuid'] = line.split('=', 1)[1].strip()
|
|
400
|
+
except (FileNotFoundError, IOError, OSError):
|
|
401
|
+
pass
|
|
402
|
+
return result
|
|
403
|
+
|
|
404
|
+
def _build_dark_device_set(self, prev_nss):
|
|
405
|
+
"""Build set of device names that should not be probed (have active jobs).
|
|
406
|
+
|
|
407
|
+
A device is "dark" if:
|
|
408
|
+
- It has an active job (ns.job is not None)
|
|
409
|
+
- Its parent disk has an active job
|
|
410
|
+
- Any of its child partitions has an active job
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
prev_nss: Previous device namespaces
|
|
414
|
+
|
|
415
|
+
Returns:
|
|
416
|
+
set: Device names to skip probing
|
|
417
|
+
"""
|
|
418
|
+
dark = set()
|
|
419
|
+
if not prev_nss:
|
|
420
|
+
return dark
|
|
421
|
+
|
|
422
|
+
for name, ns in prev_nss.items():
|
|
423
|
+
if ns.job:
|
|
424
|
+
dark.add(name)
|
|
425
|
+
# Also mark parent as dark
|
|
426
|
+
if ns.parent:
|
|
427
|
+
dark.add(ns.parent)
|
|
428
|
+
# Also mark all children as dark
|
|
429
|
+
for minor in getattr(ns, 'minors', []):
|
|
430
|
+
dark.add(minor)
|
|
431
|
+
|
|
432
|
+
return dark
|
|
433
|
+
|
|
434
|
+
def discover_devices(self, dflt, prev_nss=None):
|
|
435
|
+
"""Discover devices via /proc and /sys (non-blocking replacement for lsblk).
|
|
436
|
+
|
|
437
|
+
This method replaces parse_lsblk() to avoid blocking on devices with
|
|
438
|
+
active firmware wipe jobs. All /proc and /sys reads are non-blocking.
|
|
439
|
+
Only blkid (for fstype/label/uuid) may briefly block, and it's skipped
|
|
440
|
+
for "dark" devices (those with active jobs).
|
|
157
441
|
|
|
158
442
|
Args:
|
|
159
443
|
dflt: Default state for new devices
|
|
160
444
|
prev_nss: Previous device namespaces for merging
|
|
161
|
-
|
|
162
|
-
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
dict: Mapping of device name to SimpleNamespace
|
|
163
448
|
"""
|
|
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
|
|
449
|
+
# Build dark device set from previous state
|
|
450
|
+
dark_devices = self._build_dark_device_set(prev_nss)
|
|
200
451
|
|
|
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 {}
|
|
452
|
+
# Increment discovery cycle counter (proves code is running)
|
|
453
|
+
DeviceInfo._discovery_cycle += 1
|
|
454
|
+
|
|
455
|
+
# Phase 1: Parse /proc for basic device list and mounts
|
|
456
|
+
proc_devices = self._parse_proc_partitions()
|
|
457
|
+
if not proc_devices:
|
|
458
|
+
return {} # Critical failure - let caller handle
|
|
459
|
+
|
|
460
|
+
mounts = self._parse_proc_mounts()
|
|
461
|
+
|
|
462
|
+
# Phase 2: Build device entries from sysfs
|
|
264
463
|
entries = {}
|
|
464
|
+
parent_map = {} # name -> parent_name for building minors lists later
|
|
465
|
+
|
|
466
|
+
for name, proc_info in proc_devices.items():
|
|
467
|
+
# Get size from sysfs (more accurate than /proc/partitions blocks)
|
|
468
|
+
size_bytes = self._get_size_from_sysfs(name)
|
|
469
|
+
if size_bytes == 0:
|
|
470
|
+
size_bytes = proc_info.blocks * 1024 # Fallback to /proc/partitions
|
|
471
|
+
|
|
472
|
+
entry = self._make_partition_namespace(proc_info.major, name, size_bytes, dflt)
|
|
473
|
+
mount_info = mounts.get(name, {})
|
|
474
|
+
entry.mounts = mount_info.get('mounts', [])
|
|
475
|
+
if entry.mounts:
|
|
476
|
+
entry.fstype = mount_info.get('fstype', '')
|
|
477
|
+
|
|
478
|
+
# Determine if partition or disk
|
|
479
|
+
if self._is_partition(name):
|
|
480
|
+
entry.type = 'part'
|
|
481
|
+
parent_name = self._get_parent_from_sysfs(name)
|
|
482
|
+
if parent_name:
|
|
483
|
+
entry.parent = parent_name
|
|
484
|
+
parent_map[name] = parent_name
|
|
485
|
+
else:
|
|
486
|
+
entry.type = 'disk'
|
|
487
|
+
|
|
488
|
+
# Set mount state
|
|
489
|
+
if entry.mounts:
|
|
490
|
+
entry.state = 'Mnt'
|
|
491
|
+
|
|
492
|
+
# Check if this is a dark device
|
|
493
|
+
is_dark = name in dark_devices
|
|
494
|
+
|
|
495
|
+
# Phase 3: Conditional probing (skip for dark devices)
|
|
496
|
+
if is_dark and prev_nss and name in prev_nss:
|
|
497
|
+
# Carry forward all data from previous state
|
|
498
|
+
prev = prev_nss[name]
|
|
499
|
+
entry.fstype = prev.fstype
|
|
500
|
+
entry.label = prev.label
|
|
501
|
+
entry.uuid = prev.uuid
|
|
502
|
+
entry.serial = prev.serial
|
|
503
|
+
entry.marker = prev.marker
|
|
504
|
+
entry.hw_caps = getattr(prev, 'hw_caps', '')
|
|
505
|
+
entry.hw_caps_summary = getattr(prev, 'hw_caps_summary', '')
|
|
506
|
+
entry.hw_nopes = getattr(prev, 'hw_nopes', '')
|
|
507
|
+
entry.hw_caps_state = getattr(prev, 'hw_caps_state', ProbeState.PENDING)
|
|
508
|
+
entry.model = getattr(prev, 'model', '')
|
|
509
|
+
entry.port = getattr(prev, 'port', '')
|
|
510
|
+
entry.is_rotational = getattr(prev, 'is_rotational', False)
|
|
511
|
+
else:
|
|
512
|
+
# Non-dark device: probe for metadata
|
|
513
|
+
if entry.type == 'disk':
|
|
514
|
+
# Disk-level info from sysfs
|
|
515
|
+
entry.serial = self._get_serial_from_sysfs(name)
|
|
516
|
+
entry.model = self._get_device_vendor_model(name)
|
|
517
|
+
entry.port = self._get_port_from_sysfs(name)
|
|
518
|
+
entry.is_rotational = self._is_rotational_device(name)
|
|
519
|
+
|
|
520
|
+
# Get fstype/label/uuid via blkid (skip only if dark device)
|
|
521
|
+
if not is_dark:
|
|
522
|
+
blkid_info = self._probe_blkid(name)
|
|
523
|
+
# If not mounted, use blkid fstype; if mounted, use mount fstype
|
|
524
|
+
if not entry.mounts:
|
|
525
|
+
entry.fstype = blkid_info['fstype']
|
|
526
|
+
# Always get label and uuid from blkid
|
|
527
|
+
entry.label = blkid_info['label']
|
|
528
|
+
entry.uuid = blkid_info['uuid']
|
|
529
|
+
|
|
530
|
+
# Marker monitoring: ALWAYS update, even for dark devices
|
|
531
|
+
# This ensures markers are detected immediately after wipes
|
|
532
|
+
has_job = is_dark # Already checked above
|
|
533
|
+
has_filesystem = entry.fstype or entry.label
|
|
265
534
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
535
|
+
# Check if device has actual partitions even before we build minors list
|
|
536
|
+
has_child_partitions = any(parent_name == name for parent_name in parent_map.values())
|
|
537
|
+
|
|
538
|
+
# Marker state is now entirely managed by worker thread
|
|
539
|
+
# (no marker_checked, monitor_marker, etc. fields anymore)
|
|
540
|
+
|
|
541
|
+
# Determine if we should ask worker to monitor for marker
|
|
542
|
+
# For whole disks: no job, no children, not mounted
|
|
543
|
+
# For partitions: no job, no filesystem, not mounted
|
|
544
|
+
if entry.type == 'disk':
|
|
545
|
+
should_want_marker = (not has_job and not has_child_partitions and
|
|
546
|
+
not entry.mounts)
|
|
547
|
+
elif entry.type == 'part':
|
|
548
|
+
should_want_marker = (not has_job and not has_filesystem and
|
|
549
|
+
not entry.mounts)
|
|
550
|
+
else:
|
|
551
|
+
should_want_marker = False
|
|
552
|
+
|
|
553
|
+
# Tell worker to monitor or stop monitoring
|
|
554
|
+
if self.worker_manager:
|
|
555
|
+
self.worker_manager.set_want_marker(name, should_want_marker)
|
|
556
|
+
# Request immediate marker check for faster feedback
|
|
557
|
+
if should_want_marker:
|
|
558
|
+
self.worker_manager.request_marker_check(name)
|
|
559
|
+
|
|
560
|
+
# Get the formatted marker string from worker (if any)
|
|
561
|
+
entry.want_marker = should_want_marker
|
|
562
|
+
if should_want_marker:
|
|
563
|
+
entry.marker = self.worker_manager.get_marker_formatted(name)
|
|
564
|
+
# Extract state from marker string format: "{prefix}{state} {pct}%"
|
|
565
|
+
# State is 'W' (wiped) or 's' (scrubbing), possibly with prefix (✓ or ✗)
|
|
566
|
+
if entry.marker:
|
|
567
|
+
if 'W ' in entry.marker:
|
|
568
|
+
entry.state = 'W'
|
|
569
|
+
entry.dflt = 'W'
|
|
570
|
+
elif 's ' in entry.marker:
|
|
571
|
+
entry.state = 's'
|
|
572
|
+
entry.dflt = 's'
|
|
573
|
+
else:
|
|
574
|
+
# No marker found, reset state to default (not wiped/scrubbing)
|
|
575
|
+
entry.state = '-'
|
|
576
|
+
entry.dflt = '-'
|
|
577
|
+
else:
|
|
578
|
+
entry.marker = ''
|
|
579
|
+
# Not monitoring, reset to default state
|
|
580
|
+
entry.state = '-'
|
|
581
|
+
entry.dflt = '-'
|
|
582
|
+
|
|
583
|
+
entries[name] = entry
|
|
584
|
+
|
|
585
|
+
# Phase 4: Build parent-child relationships (minors lists)
|
|
586
|
+
for name, parent_name in parent_map.items():
|
|
587
|
+
if parent_name in entries:
|
|
588
|
+
entries[parent_name].minors.append(name)
|
|
589
|
+
self.disk_majors.add(entries[name].major)
|
|
590
|
+
# Propagate mount state to parent
|
|
591
|
+
if entries[name].mounts:
|
|
592
|
+
entries[parent_name].state = 'iMnt'
|
|
593
|
+
|
|
594
|
+
# Phase 4b: Clear marker display and state for disks with actual partitions
|
|
595
|
+
# If a disk has partitions, the partitions take precedence over the wipe marker
|
|
596
|
+
for name, entry in entries.items():
|
|
597
|
+
if entry.minors: # Disk has child partitions
|
|
598
|
+
entry.marker = '' # Don't show marker when partitions exist
|
|
599
|
+
# Also clear the state if it was set to a wipe state ('W' or 's')
|
|
600
|
+
# Reset to default state '-' (unmarked)
|
|
601
|
+
if entry.state in ('W', 's'):
|
|
602
|
+
entry.state = '-'
|
|
603
|
+
if entry.dflt in ('W', 's'):
|
|
604
|
+
entry.dflt = '-'
|
|
605
|
+
|
|
606
|
+
# Phase 5: Handle superfloppy case and clean disk rows
|
|
282
607
|
final_entries = {}
|
|
283
608
|
for name, entry in entries.items():
|
|
284
609
|
final_entries[name] = entry
|
|
285
610
|
|
|
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.
|
|
611
|
+
# Only process top-level disks
|
|
612
|
+
if entry.parent is None and entry.type == 'disk':
|
|
613
|
+
# Superfloppy: disk with filesystem but no partitions
|
|
295
614
|
if not entry.minors and (entry.fstype or entry.label or entry.mounts):
|
|
296
615
|
v_key = f"{name}_data"
|
|
297
616
|
v_child = self._make_partition_namespace(entry.major, name, entry.size_bytes, dflt)
|
|
298
617
|
v_child.name = "----"
|
|
618
|
+
v_child.type = 'part'
|
|
299
619
|
v_child.fstype = entry.fstype
|
|
300
620
|
v_child.label = entry.label
|
|
301
621
|
v_child.mounts = entry.mounts
|
|
302
622
|
v_child.parent = name
|
|
623
|
+
v_child.uuid = entry.uuid
|
|
303
624
|
|
|
304
625
|
final_entries[v_key] = v_child
|
|
305
626
|
entry.minors.append(v_key)
|
|
306
627
|
|
|
307
|
-
# Clean the
|
|
628
|
+
# Clean the disk row: show model instead of fstype
|
|
308
629
|
entry.fstype = entry.model if entry.model else 'DISK'
|
|
309
630
|
entry.label = ''
|
|
310
631
|
entry.mounts = []
|
|
311
632
|
|
|
312
|
-
|
|
633
|
+
return final_entries
|
|
313
634
|
|
|
314
|
-
return entries
|
|
315
635
|
|
|
316
636
|
@staticmethod
|
|
317
637
|
def set_one_state(nss, ns, to=None, test_to=None):
|
|
318
638
|
"""Optionally, update a state, and always set inferred states"""
|
|
319
639
|
ready_states = ('s', 'W', '-', '^')
|
|
320
640
|
job_states = ('*%', 'STOP')
|
|
321
|
-
inferred_states = ('Busy', 'Mnt',)
|
|
322
641
|
|
|
323
642
|
def state_in(to, states):
|
|
324
643
|
return to in states or fnmatch(to, states[0])
|
|
@@ -335,7 +654,7 @@ class DeviceInfo:
|
|
|
335
654
|
|
|
336
655
|
if to == 'STOP' and not state_in(ns.state, job_states):
|
|
337
656
|
return False
|
|
338
|
-
if to == 'Blk' and not state_in(ns.state, list(ready_states) + ['Mnt']):
|
|
657
|
+
if to == 'Blk' and not state_in(ns.state, list(ready_states) + ['Mnt', 'iMnt', 'iBlk']):
|
|
339
658
|
return False
|
|
340
659
|
if to == 'Unbl' and ns.state != 'Blk':
|
|
341
660
|
return False
|
|
@@ -356,9 +675,14 @@ class DeviceInfo:
|
|
|
356
675
|
|
|
357
676
|
# Here we set inferences that block starting jobs
|
|
358
677
|
# -- clearing these states will be done on the device refresh
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
678
|
+
# Propagate Mnt/iMnt from child to parent as iMnt (inherited mount)
|
|
679
|
+
if parent and ns.state in ('Mnt', 'iMnt'):
|
|
680
|
+
if parent.state not in ('Blk', 'iBlk', 'Mnt'):
|
|
681
|
+
parent.state = 'iMnt'
|
|
682
|
+
# Propagate Blk/iBlk from child to parent as iBlk (inherited block)
|
|
683
|
+
if parent and ns.state in ('Blk', 'iBlk'):
|
|
684
|
+
if parent.state != 'Blk': # Direct Blk trumps inherited
|
|
685
|
+
parent.state = 'iBlk'
|
|
362
686
|
if state_in(ns.state, job_states):
|
|
363
687
|
if parent:
|
|
364
688
|
parent.state = 'Busy'
|
|
@@ -368,8 +692,13 @@ class DeviceInfo:
|
|
|
368
692
|
|
|
369
693
|
@staticmethod
|
|
370
694
|
def clear_inferred_states(nss):
|
|
371
|
-
"""Clear all inferred states
|
|
372
|
-
|
|
695
|
+
"""Clear all inferred states so they can be re-inferred.
|
|
696
|
+
|
|
697
|
+
Inherited states (iBlk, iMnt, Busy) are cleared.
|
|
698
|
+
Direct states (Blk, Mnt) are preserved - they're set based on
|
|
699
|
+
persistent state or actual mounts, not inheritance.
|
|
700
|
+
"""
|
|
701
|
+
inferred_states = ('Busy', 'iMnt', 'iBlk')
|
|
373
702
|
for ns in nss.values():
|
|
374
703
|
if ns.state in inferred_states:
|
|
375
704
|
ns.state = ns.dflt
|
|
@@ -411,10 +740,10 @@ class DeviceInfo:
|
|
|
411
740
|
# If we can't read ro flag, skip this device to be safe
|
|
412
741
|
continue
|
|
413
742
|
|
|
414
|
-
# Exclude common virtual device prefixes as a safety net
|
|
743
|
+
# Exclude common virtual and optical device prefixes as a safety net
|
|
415
744
|
# (most should already be filtered by ro check or missing sysfs)
|
|
416
|
-
|
|
417
|
-
if any(name.startswith(prefix) for prefix in
|
|
745
|
+
excluded_prefixes = ('zram', 'loop', 'dm-', 'ram', 'sr', 'scd')
|
|
746
|
+
if any(name.startswith(prefix) for prefix in excluded_prefixes):
|
|
418
747
|
continue
|
|
419
748
|
|
|
420
749
|
# Include this device
|
|
@@ -484,8 +813,8 @@ class DeviceInfo:
|
|
|
484
813
|
# Last partition of disk: rounded corner
|
|
485
814
|
prefix = '└ '
|
|
486
815
|
else:
|
|
487
|
-
#
|
|
488
|
-
prefix = '
|
|
816
|
+
# Non-last partition: tee junction
|
|
817
|
+
prefix = '├ '
|
|
489
818
|
|
|
490
819
|
name_str = prefix + ns.name
|
|
491
820
|
|
|
@@ -493,17 +822,69 @@ class DeviceInfo:
|
|
|
493
822
|
emit += f'{sep}{Utils.human(ns.size_bytes):>{wids.human}}'
|
|
494
823
|
emit += sep + print_str_or_dash(ns.fstype, wids.fstype)
|
|
495
824
|
if ns.parent is None:
|
|
496
|
-
# Physical disk -
|
|
497
|
-
|
|
498
|
-
|
|
825
|
+
# Physical disk - show firmware capability/status centered in LABEL field
|
|
826
|
+
hw_caps = getattr(ns, 'hw_caps', '')
|
|
827
|
+
hw_nopes = getattr(ns, 'hw_nopes', '')
|
|
828
|
+
hw_summary = getattr(ns, 'hw_caps_summary', '')
|
|
829
|
+
hw_state = getattr(ns, 'hw_caps_state', ProbeState.PENDING)
|
|
830
|
+
is_usb = getattr(ns, 'is_usb', False)
|
|
831
|
+
|
|
832
|
+
fw_label = ''
|
|
833
|
+
if hw_caps:
|
|
834
|
+
fw_label = hw_summary
|
|
835
|
+
elif hw_nopes and not is_usb:
|
|
836
|
+
first_issue = hw_nopes.split(',')[0].strip()
|
|
837
|
+
fw_label = f'✗{first_issue}'
|
|
838
|
+
elif hw_state in (ProbeState.PENDING, ProbeState.PROBING):
|
|
839
|
+
if ns.state not in ('Mnt', 'iMnt', 'Blk', 'iBlk', 'Busy') and not is_usb:
|
|
840
|
+
fw_label = '...'
|
|
841
|
+
|
|
842
|
+
if not fw_label:
|
|
843
|
+
fw_label = '---'
|
|
844
|
+
|
|
845
|
+
# Split fw_label into symbol prefix and alphanumeric text for underline rendering
|
|
846
|
+
fw_symbol = ''
|
|
847
|
+
fw_text = fw_label
|
|
848
|
+
if fw_label not in ('---', '...'):
|
|
849
|
+
for i, ch in enumerate(fw_label):
|
|
850
|
+
if ch.isascii() and ch.isalnum():
|
|
851
|
+
fw_symbol = fw_label[:i]
|
|
852
|
+
fw_text = fw_label[i:]
|
|
853
|
+
break
|
|
854
|
+
|
|
855
|
+
# Record positions so caller can underline just the alphanumeric text
|
|
856
|
+
label_start = len(emit) + len(sep)
|
|
857
|
+
centered = f'{fw_label:^{wids.label}}'
|
|
858
|
+
emit += sep + centered
|
|
859
|
+
|
|
860
|
+
if fw_symbol:
|
|
861
|
+
left_pad = (wids.label - len(fw_label)) // 2
|
|
862
|
+
ul_start = label_start + left_pad + len(fw_symbol)
|
|
863
|
+
ul_end = ul_start + len(fw_text)
|
|
864
|
+
ns._fw_underline = (ul_start, ul_end)
|
|
865
|
+
else:
|
|
866
|
+
ns._fw_underline = None
|
|
867
|
+
|
|
868
|
+
# Check for aggregated mounts (from hidden children when disk is blocked)
|
|
869
|
+
agg_mounts = getattr(ns, 'aggregated_mounts', None)
|
|
870
|
+
if agg_mounts:
|
|
871
|
+
# Show first mount + count of others (e.g., "/ + 5 more")
|
|
872
|
+
first_mount = agg_mounts[0]
|
|
873
|
+
remaining = len(agg_mounts) - 1
|
|
874
|
+
if remaining > 0:
|
|
875
|
+
emit += f'{sep}{first_mount} + {remaining} more'
|
|
876
|
+
else:
|
|
877
|
+
emit += f'{sep}{first_mount}'
|
|
878
|
+
elif ns.mounts:
|
|
499
879
|
# Disk has mounts - show them
|
|
500
880
|
emit += f'{sep}{",".join(ns.mounts)}'
|
|
501
|
-
elif ns.marker and ns.marker.strip():
|
|
502
|
-
# Disk has wipe status - show it
|
|
881
|
+
elif ns.marker and ns.marker.strip() and not ns.minors:
|
|
882
|
+
# Disk has wipe status - show it ONLY if no child partitions
|
|
883
|
+
# (partitions take precedence over marker)
|
|
503
884
|
emit += f'{sep}{ns.marker}'
|
|
504
885
|
else:
|
|
505
|
-
# No status
|
|
506
|
-
emit += '
|
|
886
|
+
# No wipe status
|
|
887
|
+
emit += f'{sep} ---'
|
|
507
888
|
else:
|
|
508
889
|
# Partition: show label and mount/status info
|
|
509
890
|
emit += sep + print_str_or_dash(ns.label, wids.label)
|
|
@@ -517,7 +898,7 @@ class DeviceInfo:
|
|
|
517
898
|
# Check for newly inserted flag first (hot-swapped devices should always show orange)
|
|
518
899
|
if getattr(ns, 'newly_inserted', False):
|
|
519
900
|
# Newly inserted device - orange/bright
|
|
520
|
-
if ns.state in ('Mnt', 'Blk'):
|
|
901
|
+
if ns.state in ('Mnt', 'iMnt', 'Blk', 'iBlk'):
|
|
521
902
|
# Dim the orange for mounted/blocked devices
|
|
522
903
|
attr = curses.color_pair(Theme.HOTSWAP) | curses.A_DIM
|
|
523
904
|
else:
|
|
@@ -531,13 +912,13 @@ class DeviceInfo:
|
|
|
531
912
|
elif ns.state == 'W':
|
|
532
913
|
# Green/success color for completed wipes before this session
|
|
533
914
|
attr = curses.color_pair(Theme.OLD_SUCCESS) | curses.A_BOLD
|
|
534
|
-
elif ns.state.endswith('%') and ns.state not in ('
|
|
915
|
+
elif ns.state.endswith('%') and ns.state not in ('100%',):
|
|
535
916
|
# Active wipe in progress - bright cyan/blue with bold
|
|
536
917
|
attr = curses.color_pair(Theme.INFO) | curses.A_BOLD
|
|
537
918
|
elif ns.state == '^':
|
|
538
919
|
# Newly inserted device (hot-swapped) - orange/bright
|
|
539
920
|
attr = curses.color_pair(Theme.HOTSWAP) | curses.A_BOLD
|
|
540
|
-
elif ns.state in ('Mnt', 'Blk'):
|
|
921
|
+
elif ns.state in ('Mnt', 'iMnt', 'Blk', 'iBlk'):
|
|
541
922
|
# Dim mounted or blocked devices
|
|
542
923
|
attr = curses.A_DIM
|
|
543
924
|
|
|
@@ -545,6 +926,13 @@ class DeviceInfo:
|
|
|
545
926
|
if hasattr(ns, 'verify_failed_msg') and ns.verify_failed_msg:
|
|
546
927
|
attr = curses.color_pair(Theme.DANGER) | curses.A_BOLD
|
|
547
928
|
|
|
929
|
+
# Make disk lines bold
|
|
930
|
+
if ns.parent is None:
|
|
931
|
+
if attr is None:
|
|
932
|
+
attr = curses.A_BOLD
|
|
933
|
+
else:
|
|
934
|
+
attr |= curses.A_BOLD
|
|
935
|
+
|
|
548
936
|
return emit, attr
|
|
549
937
|
|
|
550
938
|
def merge_dev_infos(self, nss, prev_nss=None):
|
|
@@ -570,10 +958,15 @@ class DeviceInfo:
|
|
|
570
958
|
# Preserve the "wiped this session" flag
|
|
571
959
|
if hasattr(prev_ns, 'wiped_this_session'):
|
|
572
960
|
new_ns.wiped_this_session = prev_ns.wiped_this_session
|
|
573
|
-
#
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
961
|
+
# Marker is now handled entirely by worker thread, no preservation needed
|
|
962
|
+
|
|
963
|
+
# Preserve hw_caps state - once probed, it's permanent for the device
|
|
964
|
+
prev_hw_state = getattr(prev_ns, 'hw_caps_state', ProbeState.PENDING)
|
|
965
|
+
if prev_hw_state == ProbeState.READY:
|
|
966
|
+
new_ns.hw_caps = getattr(prev_ns, 'hw_caps', '')
|
|
967
|
+
new_ns.hw_caps_summary = getattr(prev_ns, 'hw_caps_summary', '')
|
|
968
|
+
new_ns.hw_nopes = getattr(prev_ns, 'hw_nopes', '')
|
|
969
|
+
new_ns.hw_caps_state = ProbeState.READY
|
|
577
970
|
|
|
578
971
|
# Preserve verify failure message ONLY for unmarked disks
|
|
579
972
|
# Clear if: filesystem appeared OR partition now has a marker
|
|
@@ -608,8 +1001,8 @@ class DeviceInfo:
|
|
|
608
1001
|
new_ns.state = 'Blk'
|
|
609
1002
|
elif new_ns.state not in ('s', 'W'):
|
|
610
1003
|
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('%'):
|
|
1004
|
+
# Don't copy forward percentage states or inherited states - only persistent states
|
|
1005
|
+
if prev_ns.state not in ('s', 'W', 'Busy', 'Unbl', 'iBlk', 'iMnt') and not prev_ns.state.endswith('%'):
|
|
613
1006
|
new_ns.state = prev_ns.state # re-infer these
|
|
614
1007
|
elif prev_ns.job:
|
|
615
1008
|
# unplugged device with job..
|
|
@@ -624,20 +1017,18 @@ class DeviceInfo:
|
|
|
624
1017
|
new_ns.newly_inserted = True # Mark for orange color even if locked/mounted
|
|
625
1018
|
return nss
|
|
626
1019
|
|
|
627
|
-
def assemble_partitions(self, prev_nss=None
|
|
1020
|
+
def assemble_partitions(self, prev_nss=None):
|
|
628
1021
|
"""Assemble and filter partitions for display
|
|
629
1022
|
|
|
630
1023
|
Args:
|
|
631
1024
|
prev_nss: Previous device namespaces for merging
|
|
632
|
-
lsblk_output: Optional lsblk JSON output string from LsblkMonitor
|
|
633
1025
|
"""
|
|
634
|
-
nss = self.
|
|
635
|
-
lsblk_output=lsblk_output)
|
|
1026
|
+
nss = self.discover_devices(dflt='^' if prev_nss else '-', prev_nss=prev_nss)
|
|
636
1027
|
|
|
637
|
-
# If
|
|
1028
|
+
# If discover_devices failed (returned empty) and we have previous data, keep previous state
|
|
638
1029
|
if not nss and prev_nss:
|
|
639
|
-
#
|
|
640
|
-
# This prevents losing devices when
|
|
1030
|
+
# Device scan failed or returned no devices - preserve previous state
|
|
1031
|
+
# This prevents losing devices when discovery temporarily fails
|
|
641
1032
|
# But clear temporary status messages from completed jobs
|
|
642
1033
|
for ns in prev_nss.values():
|
|
643
1034
|
if not ns.job and ns.mounts:
|
|
@@ -649,6 +1040,9 @@ class DeviceInfo:
|
|
|
649
1040
|
|
|
650
1041
|
nss = self.merge_dev_infos(nss, prev_nss)
|
|
651
1042
|
|
|
1043
|
+
# Update device workers for background probing
|
|
1044
|
+
self.worker_manager.update_devices(nss.keys())
|
|
1045
|
+
|
|
652
1046
|
# Apply persistent blocked states
|
|
653
1047
|
if self.persistent_state:
|
|
654
1048
|
for ns in nss.values():
|
|
@@ -660,7 +1054,13 @@ class DeviceInfo:
|
|
|
660
1054
|
|
|
661
1055
|
# Clear inferred states so they can be re-computed based on current job status
|
|
662
1056
|
self.clear_inferred_states(nss)
|
|
663
|
-
|
|
1057
|
+
|
|
1058
|
+
# Re-apply Mnt state for devices with mounts (cleared above, needed for set_all_states)
|
|
1059
|
+
for ns in nss.values():
|
|
1060
|
+
if ns.mounts:
|
|
1061
|
+
ns.state = 'Mnt'
|
|
1062
|
+
|
|
1063
|
+
self.set_all_states(nss) # set inferred states (propagates Mnt/Busy to parents)
|
|
664
1064
|
|
|
665
1065
|
self.compute_field_widths(nss)
|
|
666
1066
|
return nss
|
|
@@ -690,8 +1090,7 @@ class DeviceInfo:
|
|
|
690
1090
|
|
|
691
1091
|
# Hardware capabilities
|
|
692
1092
|
if disk.hw_caps:
|
|
693
|
-
|
|
694
|
-
print(f'│ Hardware: {caps}')
|
|
1093
|
+
print(f'│ Hardware: {disk.hw_caps}')
|
|
695
1094
|
|
|
696
1095
|
# Find and print partitions for this disk
|
|
697
1096
|
disk_parts = [(name, part) for name, part in partitions.items()
|