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/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,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='', # 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 = {}, {}
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
- # 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
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
- rv = f.read().strip()
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
- def parse_lsblk(self, dflt, prev_nss=None, lsblk_output=None):
156
- """Parse ls_blk for all the goodies we need
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
- lsblk_output: Optional lsblk JSON output string. If provided, uses this
162
- instead of running lsblk command. Useful for background monitoring.
441
+
442
+ Returns:
443
+ dict: Mapping of device name to SimpleNamespace
163
444
  """
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
445
+ # Build dark device set from previous state
446
+ dark_devices = self._build_dark_device_set(prev_nss)
200
447
 
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 {}
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
- # 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
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 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.
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 hardware row of data-specific strings
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
- entries = final_entries
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
- if parent and state_in(ns.state, inferred_states):
360
- if parent.state != 'Blk':
361
- parent.state = ns.state
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 (Busy, Mnt) so they can be re-inferred"""
372
- inferred_states = ('Busy', 'Mnt')
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
- virtual_prefixes = ('zram', 'loop', 'dm-', 'ram')
417
- if any(name.startswith(prefix) for prefix in virtual_prefixes):
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
- # Regular partition: vertical line
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 - always show thick line in LABEL field (disks don't have labels)
497
- emit += sep + '' * wids.label
498
- if ns.mounts:
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 - show heavy line divider (start 1 char left to fill gap)
506
- emit += '' + '━' * 30
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 ('0%', '100%'):
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
- # 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
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 (like "v96%") - only persistent 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, lsblk_output=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.parse_lsblk(dflt='^' if prev_nss else '-', prev_nss=prev_nss,
635
- lsblk_output=lsblk_output)
1022
+ nss = self.discover_devices(dflt='^' if prev_nss else '-', prev_nss=prev_nss)
636
1023
 
637
- # If parse_lsblk failed (returned empty) and we have previous data, keep previous state
1024
+ # If discover_devices failed (returned empty) and we have previous data, keep previous state
638
1025
  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
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
- self.set_all_states(nss) # set inferred states
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
- caps = ', '.join(disk.hw_caps.keys())
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()