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/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
- def __init__(self, opts, persistent_state=None):
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='', # persistent status
52
- marker_checked=False, # True if we've read the marker once
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={}, # hw_wipe capabilities (for whole disks)
60
- hw_nopes={}, # hw reasons cannot do hw wipe
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
- Returns cached data if already present.
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 cached results
69
- if hasattr(ns, 'hw_caps') and (ns.hw_caps or ns.hw_nopes):
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
- # Initialize defaults
73
- ns.hw_caps, ns.hw_nopes = {}, {}
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 hardware checks if firmware wipes are disabled
76
- if not getattr(self.opts, 'firmware_wipes', False):
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
- # 4. Perform the actual Probe
80
- dev_path = f"/dev/{ns.name}"
81
- if ns.name.startswith('nv'):
82
- result = self.checker.check_nvme_drive(dev_path)
83
- elif ns.name.startswith('sd'):
84
- result = self.checker.check_ata_drive(dev_path)
85
- # 5. Store Results
86
- ns.hw_caps, ns.hw_nopes = result.modes, result.issues
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
- rv = f.read().strip()
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
- def parse_lsblk(self, dflt, prev_nss=None, lsblk_output=None):
156
- """Parse ls_blk for all the goodies we need
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
- lsblk_output: Optional lsblk JSON output string. If provided, uses this
162
- instead of running lsblk command. Useful for background monitoring.
445
+
446
+ Returns:
447
+ dict: Mapping of device name to SimpleNamespace
163
448
  """
164
- def eat_one(device):
165
- entry = self._make_partition_namespace(0, '', '', dflt)
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
- # Inherit marker_checked from previous scan, or False if new/changed
202
- prev_had_filesystem = (prev_nss and entry.name in prev_nss and
203
- (prev_nss[entry.name].fstype or prev_nss[entry.name].label))
204
- filesystem_changed = prev_had_filesystem != bool(has_filesystem)
205
-
206
- if prev_nss and entry.name in prev_nss and not filesystem_changed:
207
- entry.marker_checked = prev_nss[entry.name].marker_checked
208
-
209
- # Read marker if haven't checked yet and safe to do so
210
- should_read_marker = (not mounts and not has_filesystem and not has_job and
211
- not entry.marker_checked)
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
- # Parse each block device and its properties
267
- for device in parsed_data['blockdevices']:
268
- parent = eat_one(device)
269
- entries[parent.name] = parent
270
- for child in device.get('children', []):
271
- entry = eat_one(child)
272
- entries[entry.name] = entry
273
- entry.parent = parent.name
274
- parent.minors.append(entry.name)
275
- self.disk_majors.add(entry.major)
276
- if entry.mounts:
277
- entry.state = 'Mnt'
278
- parent.state = 'Mnt'
279
-
280
-
281
- # Final pass: Identify disks, assign ports, and handle superfloppies
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 physical disks
287
- if entry.parent is None:
288
- # Hardware Info Gathering
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 hardware row of data-specific strings
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
- entries = final_entries
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
- if parent and state_in(ns.state, inferred_states):
360
- if parent.state != 'Blk':
361
- parent.state = ns.state
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 (Busy, Mnt) so they can be re-inferred"""
372
- inferred_states = ('Busy', 'Mnt')
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
- virtual_prefixes = ('zram', 'loop', 'dm-', 'ram')
417
- if any(name.startswith(prefix) for prefix in virtual_prefixes):
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
- # Regular partition: vertical line
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 - always show thick line in LABEL field (disks don't have labels)
497
- emit += sep + '' * wids.label
498
- if ns.mounts:
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 - show heavy line divider (start 1 char left to fill gap)
506
- emit += '' + '━' * 30
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 ('0%', '100%'):
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
- # Preserve marker and marker_checked (already inherited in parse_lsblk)
574
- # Only preserve marker string if we haven't just read a new one
575
- if hasattr(prev_ns, 'marker') and not new_ns.marker:
576
- new_ns.marker = prev_ns.marker
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 (like "v96%") - only persistent 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, lsblk_output=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.parse_lsblk(dflt='^' if prev_nss else '-', prev_nss=prev_nss,
635
- lsblk_output=lsblk_output)
1026
+ nss = self.discover_devices(dflt='^' if prev_nss else '-', prev_nss=prev_nss)
636
1027
 
637
- # If parse_lsblk failed (returned empty) and we have previous data, keep previous state
1028
+ # If discover_devices failed (returned empty) and we have previous data, keep previous state
638
1029
  if not nss and prev_nss:
639
- # lsblk scan failed or returned no devices - preserve previous state
640
- # This prevents losing devices when lsblk temporarily fails
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
- self.set_all_states(nss) # set inferred states
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
- caps = ', '.join(disk.hw_caps.keys())
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()