dwipe 1.0.7__py3-none-any.whl → 2.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 ADDED
@@ -0,0 +1,593 @@
1
+ """
2
+ DeviceInfo class for device discovery and information management
3
+ """
4
+ import os
5
+ import re
6
+ import json
7
+ import subprocess
8
+ import time
9
+ import datetime
10
+ import curses
11
+ from fnmatch import fnmatch
12
+ from types import SimpleNamespace
13
+
14
+ from .WipeJob import WipeJob
15
+ from .Utils import Utils
16
+ from console_window import Theme
17
+ from .PersistentState import PersistentState
18
+
19
+
20
+ class DeviceInfo:
21
+ """Class to dig out the info we want from the system."""
22
+ disk_majors = set() # major devices that are disks
23
+
24
+ def __init__(self, opts, persistent_state=None):
25
+ self.opts = opts
26
+ self.DB = opts.debug
27
+ self.wids = None
28
+ self.head_str = None
29
+ self.partitions = None
30
+ self.persistent_state = persistent_state
31
+
32
+ @staticmethod
33
+ def _make_partition_namespace(major, name, size_bytes, dflt):
34
+ return SimpleNamespace(name=name, # /proc/partitions
35
+ major=major, # /proc/partitions
36
+ parent=None, # a partition
37
+ state=dflt, # run-time state
38
+ dflt=dflt, # default run-time state
39
+ label='', # blkid
40
+ fstype='', # blkid
41
+ type='', # device type (disk, part)
42
+ model='', # /sys/class/block/{name}/device/vendor|model
43
+ size_bytes=size_bytes, # /sys/block/{name}/...
44
+ marker='', # persistent status
45
+ marker_checked=False, # True if we've read the marker once
46
+ mounts=[], # /proc/mounts
47
+ minors=[],
48
+ job=None, # if zap running
49
+ uuid='', # filesystem UUID or PARTUUID
50
+ serial='', # disk serial number (for whole disks)
51
+ port='', # port (for whole disks)
52
+ )
53
+
54
+ def _get_port_from_sysfs(self, device_name):
55
+ try:
56
+ sysfs_path = f'/sys/class/block/{device_name}'
57
+ if not os.path.exists(sysfs_path):
58
+ return ''
59
+
60
+ real_path = os.path.realpath(sysfs_path).lower()
61
+
62
+ # 1. USB - Format: USB:1-1.4
63
+ if '/usb' in real_path:
64
+ usb_match = re.search(r'/(\d+-\d+(?:\.\d+)*):', real_path)
65
+ if usb_match:
66
+ return f"USB:{usb_match.group(1)}"
67
+
68
+ # 2. SATA - Format: SATA:1
69
+ elif '/ata' in real_path:
70
+ ata_match = re.search(r'ata(\d+)', real_path)
71
+ if ata_match:
72
+ return f"SATA:{ata_match.group(1)}"
73
+
74
+ # 3. NVMe - Format: PCI:1b.0 (Stripped of 0000:00: noise)
75
+ elif '/nvme' in real_path:
76
+ # This regex ignores the 4-digit domain and the first 2-digit bus
77
+ pci_match = re.search(r'0000:[0-9a-f]{2}:([0-9a-f]{2}\.[0-9a-f])', real_path)
78
+ if pci_match:
79
+ return f"PCI:{pci_match.group(1)}"
80
+ return "NVMe"
81
+
82
+ # 4. MMC/eMMC - Format: MMC:0 or PCI:1a.0 (if PCI-attached)
83
+ elif '/mmc' in real_path:
84
+ # Try to extract mmc host number
85
+ mmc_match = re.search(r'/mmc_host/mmc(\d+)', real_path)
86
+ if mmc_match:
87
+ return f"MMC:{mmc_match.group(1)}"
88
+ # Fallback: try to get PCI address if available
89
+ pci_match = re.search(r'0000:[0-9a-f]{2}:([0-9a-f]{2}\.[0-9a-f])', real_path)
90
+ if pci_match:
91
+ return f"PCI:{pci_match.group(1)}"
92
+ return "MMC"
93
+
94
+ except Exception as e:
95
+ # Log exception to file for debugging
96
+ with open('/tmp/dwipe_port_debug.log', 'a', encoding='utf-8') as f:
97
+ import traceback
98
+ f.write(f"Exception in _get_port_from_sysfs({device_name}): {e}\n")
99
+ traceback.print_exc(file=f)
100
+ return ''
101
+
102
+ @staticmethod
103
+ def _get_device_vendor_model(device_name):
104
+ """Gets the vendor and model for a given device from the /sys/class/block directory.
105
+ - Args: - device_name: The device name, such as 'sda', 'sdb', etc.
106
+ - Returns: A string containing the vendor and model information.
107
+ """
108
+ def get_str(device_name, suffix):
109
+ try:
110
+ rv = ''
111
+ fullpath = f'/sys/class/block/{device_name}/device/{suffix}'
112
+ with open(fullpath, 'r', encoding='utf-8') as f: # Read information
113
+ rv = f.read().strip()
114
+ except (FileNotFoundError, Exception):
115
+ pass
116
+ return rv
117
+
118
+ rv = f'{get_str(device_name, "model")}'
119
+ return rv.strip()
120
+
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
159
+
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
198
+ 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 {}
206
+ entries = {}
207
+
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
224
+ final_entries = {}
225
+ for name, entry in entries.items():
226
+ 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.
237
+ if not entry.minors and (entry.fstype or entry.label or entry.mounts):
238
+ v_key = f"{name}_data"
239
+ v_child = self._make_partition_namespace(entry.major, name, entry.size_bytes, dflt)
240
+ v_child.name = "----"
241
+ v_child.fstype = entry.fstype
242
+ v_child.label = entry.label
243
+ v_child.mounts = entry.mounts
244
+ v_child.parent = name
245
+
246
+ final_entries[v_key] = v_child
247
+ entry.minors.append(v_key)
248
+
249
+ # Clean the hardware row of data-specific strings
250
+ entry.fstype = entry.model if entry.model else 'DISK'
251
+ entry.label = ''
252
+ entry.mounts = []
253
+
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))
260
+
261
+ return entries
262
+
263
+ @staticmethod
264
+ def set_one_state(nss, ns, to=None, test_to=None):
265
+ """Optionally, update a state, and always set inferred states"""
266
+ ready_states = ('s', 'W', '-', '^')
267
+ job_states = ('*%', 'STOP')
268
+ inferred_states = ('Busy', 'Mnt',)
269
+
270
+ def state_in(to, states):
271
+ return to in states or fnmatch(to, states[0])
272
+
273
+ to = test_to if test_to else to
274
+
275
+ parent, minors = None, []
276
+ if ns.parent:
277
+ parent = nss.get(ns.parent)
278
+ for minor in ns.minors:
279
+ minor_ns = nss.get(minor, None)
280
+ if minor_ns:
281
+ minors.append(minor_ns)
282
+
283
+ if to == 'STOP' and not state_in(ns.state, job_states):
284
+ return False
285
+ if to == 'Lock' and not state_in(ns.state, list(ready_states) + ['Mnt']):
286
+ return False
287
+ if to == 'Unlk' and ns.state != 'Lock':
288
+ return False
289
+
290
+ if to and fnmatch(to, '*%'):
291
+ if not state_in(ns.state, ready_states):
292
+ return False
293
+ for minor in minors:
294
+ if not state_in(minor.state, ready_states):
295
+ return False
296
+ elif to in ('s', 'W') and not state_in(ns.state, job_states):
297
+ return False
298
+ if test_to:
299
+ return True
300
+
301
+ if to is not None:
302
+ ns.state = to
303
+
304
+ # Here we set inferences that block starting jobs
305
+ # -- 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
309
+ if state_in(ns.state, job_states):
310
+ if parent:
311
+ parent.state = 'Busy'
312
+ for minor in minors:
313
+ minor.state = 'Busy'
314
+ return True
315
+
316
+ @staticmethod
317
+ def set_all_states(nss):
318
+ """Set every state per linkage inferences"""
319
+ for ns in nss.values():
320
+ DeviceInfo.set_one_state(nss, ns)
321
+
322
+ def get_disk_partitions(self, nss):
323
+ """Filter to only wipeable physical storage using positive criteria.
324
+
325
+ Keeps devices that:
326
+ - Are type 'disk' or 'part' (from lsblk)
327
+ - Are writable (not read-only)
328
+ - Are real block devices (not virtual)
329
+
330
+ This automatically excludes:
331
+ - Virtual devices (zram, loop, dm-*, etc.)
332
+ - Read-only devices (CD-ROMs, eMMC boot partitions)
333
+ - Special partitions (boot loaders)
334
+ """
335
+ ok_nss = {}
336
+ for name, ns in nss.items():
337
+ # Must be disk or partition type
338
+ if ns.type not in ('disk', 'part'):
339
+ continue
340
+
341
+ # Must be writable (excludes CD-ROMs, eMMC boot partitions, etc.)
342
+ ro_path = f'/sys/class/block/{name}/ro'
343
+ try:
344
+ with open(ro_path, 'r', encoding='utf-8') as f:
345
+ if f.read().strip() != '0':
346
+ continue # Skip read-only devices
347
+ except (FileNotFoundError, Exception):
348
+ # If we can't read ro flag, skip this device to be safe
349
+ continue
350
+
351
+ # Exclude common virtual device prefixes as a safety net
352
+ # (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):
355
+ continue
356
+
357
+ # Include this device
358
+ ok_nss[name] = ns
359
+
360
+ return ok_nss
361
+
362
+ def compute_field_widths(self, nss):
363
+ """Compute field widths for display formatting"""
364
+ wids = self.wids = SimpleNamespace(state=5, name=4, human=7, fstype=4, label=5)
365
+ for ns in nss.values():
366
+ wids.state = max(wids.state, len(ns.state))
367
+ wids.name = max(wids.name, len(ns.name) + 2)
368
+ if ns.label is None:
369
+ pass
370
+ wids.label = max(wids.label, len(ns.label))
371
+ wids.fstype = max(wids.fstype, len(ns.fstype))
372
+ 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
+
377
+ def get_head_str(self):
378
+ """Generate header string for device list"""
379
+ sep = ' '
380
+ wids = self.wids
381
+ emit = f'{"STATE":_^{wids.state}}'
382
+ emit += f'{sep}{"NAME":_^{wids.name}}'
383
+ emit += f'{sep}{"SIZE":_^{wids.human}}'
384
+ emit += f'{sep}{"TYPE":_^{wids.fstype}}'
385
+ emit += f'{sep}{"LABEL":_^{wids.label}}'
386
+ emit += f'{sep}MOUNTS/STATUS'
387
+ return emit
388
+
389
+ def get_pick_range(self):
390
+ """Calculate column range for pick highlighting (NAME through LABEL fields)"""
391
+ sep = ' '
392
+ wids = self.wids
393
+ # Start just before NAME field
394
+ start_col = wids.state + len(sep)
395
+ # End after LABEL field (always spans through LABEL for disks)
396
+ end_col = wids.state + len(sep) + wids.name + len(sep) + wids.human + len(sep) + wids.fstype # + len(sep) + wids.label
397
+ return [start_col, end_col]
398
+
399
+ def part_str(self, partition, is_last_child=False):
400
+ """Convert partition to human value.
401
+
402
+ Args:
403
+ partition: Partition namespace
404
+ is_last_child: If True and partition has parent, use └ instead of │
405
+
406
+ Returns:
407
+ tuple: (text, attr) where attr is curses attribute or None
408
+ """
409
+ def print_str_or_dash(name, width, empty='-'):
410
+ if not name.strip():
411
+ name = empty
412
+ return f'{name:^{width}}'
413
+
414
+ sep = ' '
415
+ ns = partition # shorthand
416
+ wids = self.wids
417
+ emit = f'{ns.state:^{wids.state}}'
418
+
419
+ # Determine tree prefix character
420
+ if ns.parent is None:
421
+ # Physical disk: box symbol
422
+ prefix = '■ '
423
+ elif is_last_child:
424
+ # Last partition of disk: rounded corner
425
+ prefix = '└ '
426
+ else:
427
+ # Regular partition: vertical line
428
+ prefix = '│ '
429
+
430
+ name_str = prefix + ns.name
431
+
432
+ emit += f'{sep}{name_str:<{wids.name}}'
433
+ emit += f'{sep}{Utils.human(ns.size_bytes):>{wids.human}}'
434
+ emit += sep + print_str_or_dash(ns.fstype, wids.fstype)
435
+ 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:
439
+ # Disk has mounts - show them
440
+ emit += f'{sep}{",".join(ns.mounts)}'
441
+ elif ns.marker and ns.marker.strip():
442
+ # Disk has wipe status - show it
443
+ emit += f'{sep}{ns.marker}'
444
+ else:
445
+ # No status - show heavy line divider (start 1 char left to fill gap)
446
+ emit += '━' + '━' * 30
447
+ else:
448
+ # Partition: show label and mount/status info
449
+ emit += sep + print_str_or_dash(ns.label, wids.label)
450
+ if ns.mounts:
451
+ emit += f'{sep}{",".join(ns.mounts)}'
452
+ else:
453
+ emit += f'{sep}{ns.marker}'
454
+
455
+ # Determine color attribute based on state
456
+ attr = None
457
+ # Check for newly inserted flag first (hot-swapped devices should always show orange)
458
+ if getattr(ns, 'newly_inserted', False):
459
+ # Newly inserted device - orange/bright
460
+ if ns.state in ('Mnt', 'Lock'):
461
+ # Dim the orange for mounted/locked devices
462
+ attr = curses.color_pair(Theme.HOTSWAP) | curses.A_DIM
463
+ else:
464
+ attr = curses.color_pair(Theme.HOTSWAP) | curses.A_BOLD
465
+ elif ns.state == 's':
466
+ # Yellow/warning color for stopped/partial wipes (with bold for visibility)
467
+ attr = curses.color_pair(Theme.WARNING) | curses.A_BOLD
468
+ elif ns.state == 'W' and getattr(ns, 'wiped_this_session', False):
469
+ # Green/success color for completed wipes (done in THIS session only) - bold and bright
470
+ attr = curses.color_pair(Theme.SUCCESS) | curses.A_BOLD
471
+ elif ns.state == 'W':
472
+ # Green/success color for completed wipes before this session
473
+ attr = curses.color_pair(Theme.OLD_SUCCESS) | curses.A_BOLD
474
+ elif ns.state.endswith('%') and ns.state not in ('0%', '100%'):
475
+ # Active wipe in progress - bright cyan/blue with bold
476
+ attr = curses.color_pair(Theme.INFO) | curses.A_BOLD
477
+ elif ns.state == '^':
478
+ # Newly inserted device (hot-swapped) - orange/bright
479
+ attr = curses.color_pair(Theme.HOTSWAP) | curses.A_BOLD
480
+ elif ns.state in ('Mnt', 'Lock'):
481
+ # Dim mounted or locked devices
482
+ attr = curses.A_DIM
483
+
484
+ # Override with red/danger color if verify failed
485
+ if hasattr(ns, 'verify_failed_msg') and ns.verify_failed_msg:
486
+ attr = curses.color_pair(Theme.DANGER) | curses.A_BOLD
487
+
488
+ return emit, attr
489
+
490
+ def merge_dev_infos(self, nss, prev_nss=None):
491
+ """Merge old DevInfos into new DevInfos"""
492
+ if not prev_nss:
493
+ return nss
494
+
495
+ # Track which devices were physically present in last scan
496
+ prev_physical = set()
497
+ for name, prev_ns in prev_nss.items():
498
+ # Only count as "physically present" if not carried forward due to job
499
+ if not (hasattr(prev_ns, 'was_unplugged') and prev_ns.was_unplugged):
500
+ prev_physical.add(name)
501
+
502
+ for name, prev_ns in prev_nss.items():
503
+ # merge old jobs forward
504
+ new_ns = nss.get(name, None)
505
+ if new_ns:
506
+ if prev_ns.job:
507
+ new_ns.job = prev_ns.job
508
+ # Note: Do NOT preserve port - use fresh value from current scan
509
+ new_ns.dflt = prev_ns.dflt
510
+ # Preserve the "wiped this session" flag
511
+ if hasattr(prev_ns, 'wiped_this_session'):
512
+ 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
517
+
518
+ # Preserve verify failure message ONLY for unmarked disks
519
+ # Clear if: filesystem appeared OR partition now has a marker
520
+ if hasattr(prev_ns, 'verify_failed_msg'):
521
+ # Check if partition now has marker (dflt is 'W' or 's', not '-')
522
+ has_marker = new_ns.dflt in ('W', 's')
523
+
524
+ # For whole disks (no parent): check if any child partition has filesystem
525
+ # For partitions: check if this partition has filesystem
526
+ has_filesystem = False
527
+ if not new_ns.parent:
528
+ # Whole disk - check if any child has fstype or label
529
+ for _, child_ns in nss.items():
530
+ if child_ns.parent == name and (child_ns.fstype or child_ns.label):
531
+ has_filesystem = True
532
+ break
533
+ else:
534
+ # Partition - check if it has fstype or label
535
+ has_filesystem = bool(new_ns.fstype or new_ns.label)
536
+
537
+ if has_filesystem or has_marker:
538
+ # Filesystem appeared or now has marker - clear the error
539
+ # (verify_failed_msg is only for unmarked disks)
540
+ if hasattr(new_ns, 'verify_failed_msg'):
541
+ delattr(new_ns, 'verify_failed_msg')
542
+ else:
543
+ # Still unmarked with no filesystem - persist the error
544
+ new_ns.verify_failed_msg = prev_ns.verify_failed_msg
545
+ new_ns.mounts = [prev_ns.verify_failed_msg]
546
+
547
+ if prev_ns.state == 'Lock':
548
+ new_ns.state = 'Lock'
549
+ elif new_ns.state not in ('s', 'W'):
550
+ 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('%'):
553
+ new_ns.state = prev_ns.state # re-infer these
554
+ elif prev_ns.job:
555
+ # unplugged device with job..
556
+ prev_ns.was_unplugged = True # Mark as unplugged
557
+ nss[name] = prev_ns # carry forward
558
+ prev_ns.job.do_abort = True
559
+
560
+ # Mark newly inserted devices (not present in previous physical scan)
561
+ for name, new_ns in nss.items():
562
+ if name not in prev_physical and new_ns.state not in ('s', 'W'):
563
+ new_ns.state = '^'
564
+ new_ns.newly_inserted = True # Mark for orange color even if locked/mounted
565
+ return nss
566
+
567
+ 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)
570
+
571
+ nss = self.get_disk_partitions(nss)
572
+
573
+ nss = self.merge_dev_infos(nss, prev_nss)
574
+
575
+ # Apply persistent locked states
576
+ if self.persistent_state:
577
+ for ns in nss.values():
578
+ # Update last_seen timestamp
579
+ self.persistent_state.update_device_seen(ns)
580
+ # Apply persistent lock state
581
+ if self.persistent_state.get_device_locked(ns):
582
+ ns.state = 'Lock'
583
+
584
+ self.set_all_states(nss) # set inferred states
585
+
586
+ self.compute_field_widths(nss)
587
+
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
593
+ return nss