dwipe 2.0.1__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
@@ -1,33 +1,77 @@
1
1
  """
2
2
  DeviceInfo class for device discovery and information management
3
3
  """
4
+ # pylint: disable=invalid-name,broad-exception-caught
5
+ # pylint: disable=line-too-long,too-many-locals,too-many-branches
6
+ # pylint: disable=too-many-return-statements,too-many-nested-blocks
7
+ # pylint: disable=too-many-statements
8
+
4
9
  import os
5
10
  import re
6
11
  import json
7
12
  import subprocess
8
- import time
9
- import datetime
10
13
  import curses
14
+ import traceback
11
15
  from fnmatch import fnmatch
12
16
  from types import SimpleNamespace
17
+ from console_window import Theme
18
+ from dataclasses import asdict
13
19
 
14
- from .WipeJob import WipeJob
15
20
  from .Utils import Utils
16
- from console_window import Theme
17
- from .PersistentState import PersistentState
21
+ from .DrivePreChecker import DrivePreChecker
22
+ from .DeviceWorker import DeviceWorkerManager, ProbeState
18
23
 
19
24
 
20
25
  class DeviceInfo:
21
26
  """Class to dig out the info we want from the system."""
22
27
  disk_majors = set() # major devices that are disks
28
+ _discovery_cycle = 0 # Counter to prove discovery is running
29
+
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
23
49
 
24
- def __init__(self, opts, persistent_state=None):
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):
25
63
  self.opts = opts
26
- self.DB = opts.debug
27
- self.wids = None
64
+ self.checker = DrivePreChecker()
65
+ self.wids = SimpleNamespace(state=5, name=4, human=7, fstype=4, label=5)
28
66
  self.head_str = None
29
67
  self.partitions = None
30
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)
31
75
 
32
76
  @staticmethod
33
77
  def _make_partition_namespace(major, name, size_bytes, dflt):
@@ -41,16 +85,53 @@ class DeviceInfo:
41
85
  type='', # device type (disk, part)
42
86
  model='', # /sys/class/block/{name}/device/vendor|model
43
87
  size_bytes=size_bytes, # /sys/block/{name}/...
44
- marker='', # persistent status
45
- 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
46
90
  mounts=[], # /proc/mounts
47
91
  minors=[],
48
92
  job=None, # if zap running
49
93
  uuid='', # filesystem UUID or PARTUUID
50
94
  serial='', # disk serial number (for whole disks)
51
95
  port='', # port (for whole disks)
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)
52
102
  )
53
103
 
104
+ def get_hw_capabilities(self, ns):
105
+ """
106
+ Populates and returns hardware wipe capabilities for a disk.
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.
110
+ """
111
+ # 1. Check if we already have final results
112
+ if ns.hw_caps_state == ProbeState.READY:
113
+ return ns.hw_caps, ns.hw_nopes
114
+
115
+ # 2. Skip probing if device has active job (would block on SATA wipe)
116
+ if ns.job:
117
+ return ns.hw_caps, ns.hw_nopes
118
+
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
+
133
+ return ns.hw_caps, ns.hw_nopes
134
+
54
135
  def _get_port_from_sysfs(self, device_name):
55
136
  try:
56
137
  sysfs_path = f'/sys/class/block/{device_name}'
@@ -94,7 +175,6 @@ class DeviceInfo:
94
175
  except Exception as e:
95
176
  # Log exception to file for debugging
96
177
  with open('/tmp/dwipe_port_debug.log', 'a', encoding='utf-8') as f:
97
- import traceback
98
178
  f.write(f"Exception in _get_port_from_sysfs({device_name}): {e}\n")
99
179
  traceback.print_exc(file=f)
100
180
  return ''
@@ -110,7 +190,8 @@ class DeviceInfo:
110
190
  rv = ''
111
191
  fullpath = f'/sys/class/block/{device_name}/device/{suffix}'
112
192
  with open(fullpath, 'r', encoding='utf-8') as f: # Read information
113
- rv = f.read().strip()
193
+ # Sanitize: some USB bridges return strings with embedded nulls
194
+ rv = f.read().strip().replace('\x00', '')
114
195
  except (FileNotFoundError, Exception):
115
196
  pass
116
197
  return rv
@@ -118,154 +199,441 @@ class DeviceInfo:
118
199
  rv = f'{get_str(device_name, "model")}'
119
200
  return rv.strip()
120
201
 
121
- def parse_lsblk(self, dflt, prev_nss=None):
122
- """Parse ls_blk for all the goodies we need"""
123
- def eat_one(device):
124
- entry = self._make_partition_namespace(0, '', '', dflt)
125
- entry.name = device.get('name', '')
126
- maj_min = device.get('maj:min', (-1, -1))
127
- wds = maj_min.split(':', maxsplit=1)
128
- entry.major = -1
129
- if len(wds) > 0:
130
- entry.major = int(wds[0])
131
- entry.fstype = device.get('fstype', '')
132
- if entry.fstype is None:
133
- entry.fstype = ''
134
- entry.type = device.get('type', '')
135
- entry.label = device.get('label', '')
136
- if not entry.label:
137
- entry.label = device.get('partlabel', '')
138
- if entry.label is None:
139
- entry.label = ''
140
- entry.size_bytes = int(device.get('size', 0))
141
-
142
- # Get UUID - prefer PARTUUID for partitions, UUID for filesystems
143
- entry.uuid = device.get('partuuid', '') or device.get('uuid', '') or ''
144
- entry.serial = device.get('serial', '') or ''
145
-
146
- mounts = device.get('mountpoints', [])
147
- while len(mounts) >= 1 and mounts[0] is None:
148
- del mounts[0]
149
- entry.mounts = mounts
150
-
151
- # Check if we should read the marker (3-state model: dont-know, got-marker, no-marker)
152
- # Read marker ONCE when:
153
- # 1. Not mounted
154
- # 2. No filesystem (fstype/label empty)
155
- # 3. No active job
156
- # 4. Haven't checked yet (marker_checked=False)
157
- has_job = prev_nss and entry.name in prev_nss and getattr(prev_nss[entry.name], 'job', None) is not None
158
- has_filesystem = entry.fstype or entry.label
202
+ @staticmethod
203
+ def _is_rotational_device(device_name):
204
+ """Check if device is rotational (HDD) vs solid-state (SSD).
159
205
 
160
- # Inherit marker_checked from previous scan, or False if new/changed
161
- prev_had_filesystem = (prev_nss and entry.name in prev_nss and
162
- (prev_nss[entry.name].fstype or prev_nss[entry.name].label))
163
- filesystem_changed = prev_had_filesystem != bool(has_filesystem)
164
-
165
- if prev_nss and entry.name in prev_nss and not filesystem_changed:
166
- entry.marker_checked = prev_nss[entry.name].marker_checked
167
-
168
- # Read marker if haven't checked yet and safe to do so
169
- should_read_marker = (not mounts and not has_filesystem and not has_job and
170
- not entry.marker_checked)
171
-
172
- if should_read_marker:
173
- entry.marker_checked = True # Mark as checked regardless of result
174
- marker = WipeJob.read_marker_buffer(entry.name)
175
- now = int(round(time.time()))
176
- if (marker and marker.size_bytes == entry.size_bytes
177
- and marker.unixtime < now):
178
- # For multi-pass wipes, scrubbed_bytes can exceed size_bytes
179
- # Calculate completion percentage (capped at 100%)
180
- pct = min(100, int(round((marker.scrubbed_bytes / marker.size_bytes) * 100)))
181
- state = 'W' if pct >= 100 else 's'
182
- dt = datetime.datetime.fromtimestamp(marker.unixtime)
183
- # Add verification status prefix
184
- verify_prefix = ''
185
- verify_status = getattr(marker, 'verify_status', None)
186
- if verify_status == 'pass':
187
- verify_prefix = '✓ '
188
- elif verify_status == 'fail':
189
- verify_prefix = '✗ '
190
- entry.marker = f'{verify_prefix}{state} {pct}% {marker.mode} {dt.strftime("%Y/%m/%d %H:%M")}'
191
- entry.state = state
192
- entry.dflt = state # Set dflt so merge logic knows this partition has a marker
193
-
194
- return entry
195
-
196
- # Run the `lsblk` command and get its output in JSON format with additional columns
197
- # Use timeout to prevent UI freeze if lsblk hangs on problematic devices
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
+ """
198
213
  try:
199
- result = subprocess.run(['lsblk', '-J', '--bytes', '-o',
200
- 'NAME,MAJ:MIN,FSTYPE,TYPE,LABEL,PARTLABEL,FSUSE%,SIZE,MOUNTPOINTS,UUID,PARTUUID,SERIAL'],
201
- stdout=subprocess.PIPE, text=True, check=False, timeout=10.0)
202
- parsed_data = json.loads(result.stdout)
203
- except subprocess.TimeoutExpired:
204
- # lsblk hung - return empty dict to use previous device state
205
- return {}
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).
437
+
438
+ Args:
439
+ dflt: Default state for new devices
440
+ prev_nss: Previous device namespaces for merging
441
+
442
+ Returns:
443
+ dict: Mapping of device name to SimpleNamespace
444
+ """
445
+ # Build dark device set from previous state
446
+ dark_devices = self._build_dark_device_set(prev_nss)
447
+
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
206
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
207
530
 
208
- # Parse each block device and its properties
209
- for device in parsed_data['blockdevices']:
210
- parent = eat_one(device)
211
- entries[parent.name] = parent
212
- for child in device.get('children', []):
213
- entry = eat_one(child)
214
- entries[entry.name] = entry
215
- entry.parent = parent.name
216
- parent.minors.append(entry.name)
217
- self.disk_majors.add(entry.major)
218
- if entry.mounts:
219
- entry.state = 'Mnt'
220
- parent.state = 'Mnt'
221
-
222
-
223
- # 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
224
603
  final_entries = {}
225
604
  for name, entry in entries.items():
226
605
  final_entries[name] = entry
227
-
228
- # Only process top-level physical disks
229
- if entry.parent is None:
230
- # Hardware Info Gathering
231
- entry.model = self._get_device_vendor_model(entry.name)
232
- entry.port = self._get_port_from_sysfs(entry.name)
233
-
234
- # The Split (Superfloppy Case)
235
- # If it has children, the children already hold the data.
236
- # If it has NO children but HAS data, we create the '----' child.
606
+
607
+ # Only process top-level disks
608
+ if entry.parent is None and entry.type == 'disk':
609
+ # Superfloppy: disk with filesystem but no partitions
237
610
  if not entry.minors and (entry.fstype or entry.label or entry.mounts):
238
611
  v_key = f"{name}_data"
239
612
  v_child = self._make_partition_namespace(entry.major, name, entry.size_bytes, dflt)
240
613
  v_child.name = "----"
614
+ v_child.type = 'part'
241
615
  v_child.fstype = entry.fstype
242
616
  v_child.label = entry.label
243
617
  v_child.mounts = entry.mounts
244
618
  v_child.parent = name
619
+ v_child.uuid = entry.uuid
245
620
 
246
621
  final_entries[v_key] = v_child
247
622
  entry.minors.append(v_key)
248
-
249
- # Clean the hardware row of data-specific strings
623
+
624
+ # Clean the disk row: show model instead of fstype
250
625
  entry.fstype = entry.model if entry.model else 'DISK'
251
626
  entry.label = ''
252
627
  entry.mounts = []
253
628
 
254
- entries = final_entries
255
-
256
- if self.DB:
257
- print('\n\nDB: --->>> after parse_lsblk:')
258
- for entry in entries.values():
259
- print(vars(entry))
629
+ return final_entries
260
630
 
261
- return entries
262
631
 
263
632
  @staticmethod
264
633
  def set_one_state(nss, ns, to=None, test_to=None):
265
634
  """Optionally, update a state, and always set inferred states"""
266
635
  ready_states = ('s', 'W', '-', '^')
267
636
  job_states = ('*%', 'STOP')
268
- inferred_states = ('Busy', 'Mnt',)
269
637
 
270
638
  def state_in(to, states):
271
639
  return to in states or fnmatch(to, states[0])
@@ -282,9 +650,9 @@ class DeviceInfo:
282
650
 
283
651
  if to == 'STOP' and not state_in(ns.state, job_states):
284
652
  return False
285
- if to == 'Lock' 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']):
286
654
  return False
287
- if to == 'Unlk' and ns.state != 'Lock':
655
+ if to == 'Unbl' and ns.state != 'Blk':
288
656
  return False
289
657
 
290
658
  if to and fnmatch(to, '*%'):
@@ -303,9 +671,14 @@ class DeviceInfo:
303
671
 
304
672
  # Here we set inferences that block starting jobs
305
673
  # -- clearing these states will be done on the device refresh
306
- if parent and state_in(ns.state, inferred_states):
307
- if parent.state != 'Lock':
308
- 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'
309
682
  if state_in(ns.state, job_states):
310
683
  if parent:
311
684
  parent.state = 'Busy'
@@ -313,6 +686,19 @@ class DeviceInfo:
313
686
  minor.state = 'Busy'
314
687
  return True
315
688
 
689
+ @staticmethod
690
+ def clear_inferred_states(nss):
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')
698
+ for ns in nss.values():
699
+ if ns.state in inferred_states:
700
+ ns.state = ns.dflt
701
+
316
702
  @staticmethod
317
703
  def set_all_states(nss):
318
704
  """Set every state per linkage inferences"""
@@ -337,6 +723,8 @@ class DeviceInfo:
337
723
  # Must be disk or partition type
338
724
  if ns.type not in ('disk', 'part'):
339
725
  continue
726
+ if ns.size_bytes <= 0: # not relevant to wiping
727
+ continue
340
728
 
341
729
  # Must be writable (excludes CD-ROMs, eMMC boot partitions, etc.)
342
730
  ro_path = f'/sys/class/block/{name}/ro'
@@ -348,10 +736,10 @@ class DeviceInfo:
348
736
  # If we can't read ro flag, skip this device to be safe
349
737
  continue
350
738
 
351
- # Exclude common virtual device prefixes as a safety net
739
+ # Exclude common virtual and optical device prefixes as a safety net
352
740
  # (most should already be filtered by ro check or missing sysfs)
353
- virtual_prefixes = ('zram', 'loop', 'dm-', 'ram')
354
- 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):
355
743
  continue
356
744
 
357
745
  # Include this device
@@ -361,7 +749,7 @@ class DeviceInfo:
361
749
 
362
750
  def compute_field_widths(self, nss):
363
751
  """Compute field widths for display formatting"""
364
- wids = self.wids = SimpleNamespace(state=5, name=4, human=7, fstype=4, label=5)
752
+ wids = self.wids
365
753
  for ns in nss.values():
366
754
  wids.state = max(wids.state, len(ns.state))
367
755
  wids.name = max(wids.name, len(ns.name) + 2)
@@ -370,9 +758,6 @@ class DeviceInfo:
370
758
  wids.label = max(wids.label, len(ns.label))
371
759
  wids.fstype = max(wids.fstype, len(ns.fstype))
372
760
  self.head_str = self.get_head_str()
373
- if self.DB:
374
- print('\n\nDB: --->>> after compute_field_widths():')
375
- print(f'self.wids={vars(wids)}')
376
761
 
377
762
  def get_head_str(self):
378
763
  """Generate header string for device list"""
@@ -424,8 +809,8 @@ class DeviceInfo:
424
809
  # Last partition of disk: rounded corner
425
810
  prefix = '└ '
426
811
  else:
427
- # Regular partition: vertical line
428
- prefix = ' '
812
+ # Non-last partition: tee junction
813
+ prefix = ' '
429
814
 
430
815
  name_str = prefix + ns.name
431
816
 
@@ -433,17 +818,69 @@ class DeviceInfo:
433
818
  emit += f'{sep}{Utils.human(ns.size_bytes):>{wids.human}}'
434
819
  emit += sep + print_str_or_dash(ns.fstype, wids.fstype)
435
820
  if ns.parent is None:
436
- # Physical disk - always show thick line in LABEL field (disks don't have labels)
437
- emit += sep + '' * wids.label
438
- 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:
439
875
  # Disk has mounts - show them
440
876
  emit += f'{sep}{",".join(ns.mounts)}'
441
- elif ns.marker and ns.marker.strip():
442
- # 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)
443
880
  emit += f'{sep}{ns.marker}'
444
881
  else:
445
- # No status - show heavy line divider (start 1 char left to fill gap)
446
- emit += '' + '━' * 30
882
+ # No wipe status
883
+ emit += f'{sep} ---'
447
884
  else:
448
885
  # Partition: show label and mount/status info
449
886
  emit += sep + print_str_or_dash(ns.label, wids.label)
@@ -457,8 +894,8 @@ class DeviceInfo:
457
894
  # Check for newly inserted flag first (hot-swapped devices should always show orange)
458
895
  if getattr(ns, 'newly_inserted', False):
459
896
  # Newly inserted device - orange/bright
460
- if ns.state in ('Mnt', 'Lock'):
461
- # Dim the orange for mounted/locked devices
897
+ if ns.state in ('Mnt', 'iMnt', 'Blk', 'iBlk'):
898
+ # Dim the orange for mounted/blocked devices
462
899
  attr = curses.color_pair(Theme.HOTSWAP) | curses.A_DIM
463
900
  else:
464
901
  attr = curses.color_pair(Theme.HOTSWAP) | curses.A_BOLD
@@ -471,20 +908,27 @@ class DeviceInfo:
471
908
  elif ns.state == 'W':
472
909
  # Green/success color for completed wipes before this session
473
910
  attr = curses.color_pair(Theme.OLD_SUCCESS) | curses.A_BOLD
474
- elif ns.state.endswith('%') and ns.state not in ('0%', '100%'):
911
+ elif ns.state.endswith('%') and ns.state not in ('100%',):
475
912
  # Active wipe in progress - bright cyan/blue with bold
476
913
  attr = curses.color_pair(Theme.INFO) | curses.A_BOLD
477
914
  elif ns.state == '^':
478
915
  # Newly inserted device (hot-swapped) - orange/bright
479
916
  attr = curses.color_pair(Theme.HOTSWAP) | curses.A_BOLD
480
- elif ns.state in ('Mnt', 'Lock'):
481
- # Dim mounted or locked devices
917
+ elif ns.state in ('Mnt', 'iMnt', 'Blk', 'iBlk'):
918
+ # Dim mounted or blocked devices
482
919
  attr = curses.A_DIM
483
920
 
484
921
  # Override with red/danger color if verify failed
485
922
  if hasattr(ns, 'verify_failed_msg') and ns.verify_failed_msg:
486
923
  attr = curses.color_pair(Theme.DANGER) | curses.A_BOLD
487
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
+
488
932
  return emit, attr
489
933
 
490
934
  def merge_dev_infos(self, nss, prev_nss=None):
@@ -510,10 +954,15 @@ class DeviceInfo:
510
954
  # Preserve the "wiped this session" flag
511
955
  if hasattr(prev_ns, 'wiped_this_session'):
512
956
  new_ns.wiped_this_session = prev_ns.wiped_this_session
513
- # Preserve marker and marker_checked (already inherited in parse_lsblk)
514
- # Only preserve marker string if we haven't just read a new one
515
- if hasattr(prev_ns, 'marker') and not new_ns.marker:
516
- 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
517
966
 
518
967
  # Preserve verify failure message ONLY for unmarked disks
519
968
  # Clear if: filesystem appeared OR partition now has a marker
@@ -544,12 +993,12 @@ class DeviceInfo:
544
993
  new_ns.verify_failed_msg = prev_ns.verify_failed_msg
545
994
  new_ns.mounts = [prev_ns.verify_failed_msg]
546
995
 
547
- if prev_ns.state == 'Lock':
548
- new_ns.state = 'Lock'
996
+ if prev_ns.state == 'Blk':
997
+ new_ns.state = 'Blk'
549
998
  elif new_ns.state not in ('s', 'W'):
550
999
  new_ns.state = new_ns.dflt
551
- # Don't copy forward percentage states (like "v96%") - only persistent states
552
- if prev_ns.state not in ('s', 'W', 'Busy', 'Unlk') 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('%'):
553
1002
  new_ns.state = prev_ns.state # re-infer these
554
1003
  elif prev_ns.job:
555
1004
  # unplugged device with job..
@@ -565,29 +1014,106 @@ class DeviceInfo:
565
1014
  return nss
566
1015
 
567
1016
  def assemble_partitions(self, prev_nss=None):
568
- """Assemble and filter partitions for display"""
569
- nss = self.parse_lsblk(dflt='^' if prev_nss else '-', prev_nss=prev_nss)
1017
+ """Assemble and filter partitions for display
1018
+
1019
+ Args:
1020
+ prev_nss: Previous device namespaces for merging
1021
+ """
1022
+ nss = self.discover_devices(dflt='^' if prev_nss else '-', prev_nss=prev_nss)
1023
+
1024
+ # If discover_devices failed (returned empty) and we have previous data, keep previous state
1025
+ if not nss and prev_nss:
1026
+ # Device scan failed or returned no devices - preserve previous state
1027
+ # This prevents losing devices when discovery temporarily fails
1028
+ # But clear temporary status messages from completed jobs
1029
+ for ns in prev_nss.values():
1030
+ if not ns.job and ns.mounts:
1031
+ # Job finished - clear temporary status messages like "Verified: zeroed"
1032
+ ns.mounts = [m for m in ns.mounts if not m.startswith(('Verified:', 'Stopped'))]
1033
+ return prev_nss # Return early - don't reprocess
570
1034
 
571
1035
  nss = self.get_disk_partitions(nss)
572
1036
 
573
1037
  nss = self.merge_dev_infos(nss, prev_nss)
574
1038
 
575
- # Apply persistent locked states
1039
+ # Update device workers for background probing
1040
+ self.worker_manager.update_devices(nss.keys())
1041
+
1042
+ # Apply persistent blocked states
576
1043
  if self.persistent_state:
577
1044
  for ns in nss.values():
578
1045
  # Update last_seen timestamp
579
1046
  self.persistent_state.update_device_seen(ns)
580
- # Apply persistent lock state
1047
+ # Apply persistent block state
581
1048
  if self.persistent_state.get_device_locked(ns):
582
- ns.state = 'Lock'
1049
+ ns.state = 'Blk'
583
1050
 
584
- self.set_all_states(nss) # set inferred states
1051
+ # Clear inferred states so they can be re-computed based on current job status
1052
+ self.clear_inferred_states(nss)
585
1053
 
586
- self.compute_field_widths(nss)
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'
587
1058
 
588
- if self.DB:
589
- print('\n\nDB: --->>> after assemble_partitions():')
590
- for name, ns in nss.items():
591
- print(f'DB: {name}: {vars(ns)}')
592
- self.partitions = nss
1059
+ self.set_all_states(nss) # set inferred states (propagates Mnt/Busy to parents)
1060
+
1061
+ self.compute_field_widths(nss)
593
1062
  return nss
1063
+
1064
+ @staticmethod
1065
+ def dump(parts=None, title='after lsblk'):
1066
+ """Print nicely formatted device information"""
1067
+ if not parts:
1068
+ return
1069
+
1070
+ print(f'\n{"=" * 80}')
1071
+ print(f'{title}')
1072
+ print(f'{"=" * 80}\n')
1073
+
1074
+ # Separate disks and partitions
1075
+ disks = {name: part for name, part in parts.items() if part.type == 'disk'}
1076
+ partitions = {name: part for name, part in parts.items() if part.type == 'part'}
1077
+
1078
+ # Print each disk with its partitions
1079
+ for disk_name in sorted(disks.keys()):
1080
+ disk = disks[disk_name]
1081
+
1082
+ # Disk header
1083
+ print(f'┌─ {disk.name} ({disk.model or "Unknown Model"})')
1084
+ print(f'│ Size: {Utils.human(disk.size_bytes)} Serial: {disk.serial or "N/A"} Port: {disk.port or "N/A"}')
1085
+ print(f'│ State: {disk.state} Marker: {disk.marker or "(none)"}')
1086
+
1087
+ # Hardware capabilities
1088
+ if disk.hw_caps:
1089
+ print(f'│ Hardware: {disk.hw_caps}')
1090
+
1091
+ # Find and print partitions for this disk
1092
+ disk_parts = [(name, part) for name, part in partitions.items()
1093
+ if part.parent == disk.name]
1094
+
1095
+ if disk_parts:
1096
+ for i, (part_name, part) in enumerate(sorted(disk_parts)):
1097
+ is_last = (i == len(disk_parts) - 1)
1098
+ branch = '└─' if is_last else '├─'
1099
+
1100
+ # Partition info
1101
+ label_str = f'"{part.label}"' if part.label else '(no label)'
1102
+ fstype_str = part.fstype or '(no filesystem)'
1103
+
1104
+ print(f'│ {branch} {part.name}: {Utils.human(part.size_bytes)}')
1105
+ print(f'│ {" " if is_last else "│ "} Label: {label_str} Type: {fstype_str}')
1106
+ print(f'│ {" " if is_last else "│ "} State: {part.state} UUID: {part.uuid or "N/A"}')
1107
+
1108
+ if part.marker:
1109
+ print(f'│ {" " if is_last else "│ "} Marker: {part.marker}')
1110
+
1111
+ if part.mounts:
1112
+ mounts_str = ', '.join(part.mounts)
1113
+ print(f'│ {" " if is_last else "│ "} Mounted: {mounts_str}')
1114
+ else:
1115
+ print(f'│ └─ (no partitions)')
1116
+
1117
+ print('│')
1118
+
1119
+ print(f'{"=" * 80}\n')