dwipe 2.0.0__py3-none-any.whl → 2.0.2__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 +291 -59
- dwipe/DiskWipe.py +497 -172
- dwipe/DrivePreChecker.py +90 -0
- dwipe/FirmwareWipeTask.py +370 -0
- dwipe/LsblkMonitor.py +124 -0
- dwipe/PersistentState.py +28 -18
- dwipe/Prereqs.py +84 -0
- dwipe/StructuredLogger.py +643 -0
- dwipe/ToolManager.py +618 -0
- dwipe/Utils.py +108 -0
- dwipe/VerifyTask.py +410 -0
- dwipe/WipeJob.py +613 -165
- dwipe/WipeTask.py +148 -0
- dwipe/WriteTask.py +402 -0
- dwipe/main.py +14 -9
- {dwipe-2.0.0.dist-info → dwipe-2.0.2.dist-info}/METADATA +69 -30
- dwipe-2.0.2.dist-info/RECORD +21 -0
- dwipe/WipeJobFuture.py +0 -245
- dwipe-2.0.0.dist-info/RECORD +0 -13
- {dwipe-2.0.0.dist-info → dwipe-2.0.2.dist-info}/WHEEL +0 -0
- {dwipe-2.0.0.dist-info → dwipe-2.0.2.dist-info}/entry_points.txt +0 -0
- {dwipe-2.0.0.dist-info → dwipe-2.0.2.dist-info}/licenses/LICENSE +0 -0
dwipe/DeviceInfo.py
CHANGED
|
@@ -1,18 +1,27 @@
|
|
|
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
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
4
11
|
import json
|
|
5
12
|
import subprocess
|
|
6
13
|
import time
|
|
7
14
|
import datetime
|
|
8
15
|
import curses
|
|
16
|
+
import traceback
|
|
9
17
|
from fnmatch import fnmatch
|
|
10
18
|
from types import SimpleNamespace
|
|
19
|
+
from console_window import Theme
|
|
20
|
+
from dataclasses import asdict
|
|
11
21
|
|
|
12
22
|
from .WipeJob import WipeJob
|
|
13
23
|
from .Utils import Utils
|
|
14
|
-
from
|
|
15
|
-
from .PersistentState import PersistentState
|
|
24
|
+
from .DrivePreChecker import DrivePreChecker
|
|
16
25
|
|
|
17
26
|
|
|
18
27
|
class DeviceInfo:
|
|
@@ -21,8 +30,8 @@ class DeviceInfo:
|
|
|
21
30
|
|
|
22
31
|
def __init__(self, opts, persistent_state=None):
|
|
23
32
|
self.opts = opts
|
|
24
|
-
self.
|
|
25
|
-
self.wids =
|
|
33
|
+
self.checker = DrivePreChecker()
|
|
34
|
+
self.wids = SimpleNamespace(state=5, name=4, human=7, fstype=4, label=5)
|
|
26
35
|
self.head_str = None
|
|
27
36
|
self.partitions = None
|
|
28
37
|
self.persistent_state = persistent_state
|
|
@@ -36,6 +45,7 @@ class DeviceInfo:
|
|
|
36
45
|
dflt=dflt, # default run-time state
|
|
37
46
|
label='', # blkid
|
|
38
47
|
fstype='', # blkid
|
|
48
|
+
type='', # device type (disk, part)
|
|
39
49
|
model='', # /sys/class/block/{name}/device/vendor|model
|
|
40
50
|
size_bytes=size_bytes, # /sys/block/{name}/...
|
|
41
51
|
marker='', # persistent status
|
|
@@ -45,10 +55,86 @@ class DeviceInfo:
|
|
|
45
55
|
job=None, # if zap running
|
|
46
56
|
uuid='', # filesystem UUID or PARTUUID
|
|
47
57
|
serial='', # disk serial number (for whole disks)
|
|
58
|
+
port='', # port (for whole disks)
|
|
59
|
+
hw_caps={}, # hw_wipe capabilities (for whole disks)
|
|
60
|
+
hw_nopes={}, # hw reasons cannot do hw wipe
|
|
48
61
|
)
|
|
49
62
|
|
|
63
|
+
def get_hw_capabilities(self, ns):
|
|
64
|
+
"""
|
|
65
|
+
Populates and returns hardware wipe capabilities for a disk.
|
|
66
|
+
Returns cached data if already present.
|
|
67
|
+
"""
|
|
68
|
+
# 1. Check if we already have cached results
|
|
69
|
+
if hasattr(ns, 'hw_caps') and (ns.hw_caps or ns.hw_nopes):
|
|
70
|
+
return ns.hw_caps, ns.hw_nopes
|
|
71
|
+
|
|
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):
|
|
77
|
+
return ns.hw_caps, ns.hw_nopes
|
|
78
|
+
|
|
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
|
|
87
|
+
return ns.hw_caps, ns.hw_nopes
|
|
88
|
+
|
|
89
|
+
def _get_port_from_sysfs(self, device_name):
|
|
90
|
+
try:
|
|
91
|
+
sysfs_path = f'/sys/class/block/{device_name}'
|
|
92
|
+
if not os.path.exists(sysfs_path):
|
|
93
|
+
return ''
|
|
94
|
+
|
|
95
|
+
real_path = os.path.realpath(sysfs_path).lower()
|
|
96
|
+
|
|
97
|
+
# 1. USB - Format: USB:1-1.4
|
|
98
|
+
if '/usb' in real_path:
|
|
99
|
+
usb_match = re.search(r'/(\d+-\d+(?:\.\d+)*):', real_path)
|
|
100
|
+
if usb_match:
|
|
101
|
+
return f"USB:{usb_match.group(1)}"
|
|
102
|
+
|
|
103
|
+
# 2. SATA - Format: SATA:1
|
|
104
|
+
elif '/ata' in real_path:
|
|
105
|
+
ata_match = re.search(r'ata(\d+)', real_path)
|
|
106
|
+
if ata_match:
|
|
107
|
+
return f"SATA:{ata_match.group(1)}"
|
|
108
|
+
|
|
109
|
+
# 3. NVMe - Format: PCI:1b.0 (Stripped of 0000:00: noise)
|
|
110
|
+
elif '/nvme' in real_path:
|
|
111
|
+
# This regex ignores the 4-digit domain and the first 2-digit bus
|
|
112
|
+
pci_match = re.search(r'0000:[0-9a-f]{2}:([0-9a-f]{2}\.[0-9a-f])', real_path)
|
|
113
|
+
if pci_match:
|
|
114
|
+
return f"PCI:{pci_match.group(1)}"
|
|
115
|
+
return "NVMe"
|
|
116
|
+
|
|
117
|
+
# 4. MMC/eMMC - Format: MMC:0 or PCI:1a.0 (if PCI-attached)
|
|
118
|
+
elif '/mmc' in real_path:
|
|
119
|
+
# Try to extract mmc host number
|
|
120
|
+
mmc_match = re.search(r'/mmc_host/mmc(\d+)', real_path)
|
|
121
|
+
if mmc_match:
|
|
122
|
+
return f"MMC:{mmc_match.group(1)}"
|
|
123
|
+
# Fallback: try to get PCI address if available
|
|
124
|
+
pci_match = re.search(r'0000:[0-9a-f]{2}:([0-9a-f]{2}\.[0-9a-f])', real_path)
|
|
125
|
+
if pci_match:
|
|
126
|
+
return f"PCI:{pci_match.group(1)}"
|
|
127
|
+
return "MMC"
|
|
128
|
+
|
|
129
|
+
except Exception as e:
|
|
130
|
+
# Log exception to file for debugging
|
|
131
|
+
with open('/tmp/dwipe_port_debug.log', 'a', encoding='utf-8') as f:
|
|
132
|
+
f.write(f"Exception in _get_port_from_sysfs({device_name}): {e}\n")
|
|
133
|
+
traceback.print_exc(file=f)
|
|
134
|
+
return ''
|
|
135
|
+
|
|
50
136
|
@staticmethod
|
|
51
|
-
def
|
|
137
|
+
def _get_device_vendor_model(device_name):
|
|
52
138
|
"""Gets the vendor and model for a given device from the /sys/class/block directory.
|
|
53
139
|
- Args: - device_name: The device name, such as 'sda', 'sdb', etc.
|
|
54
140
|
- Returns: A string containing the vendor and model information.
|
|
@@ -66,8 +152,15 @@ class DeviceInfo:
|
|
|
66
152
|
rv = f'{get_str(device_name, "model")}'
|
|
67
153
|
return rv.strip()
|
|
68
154
|
|
|
69
|
-
def parse_lsblk(self, dflt, prev_nss=None):
|
|
70
|
-
"""Parse ls_blk for all the goodies we need
|
|
155
|
+
def parse_lsblk(self, dflt, prev_nss=None, lsblk_output=None):
|
|
156
|
+
"""Parse ls_blk for all the goodies we need
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
dflt: Default state for new devices
|
|
160
|
+
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.
|
|
163
|
+
"""
|
|
71
164
|
def eat_one(device):
|
|
72
165
|
entry = self._make_partition_namespace(0, '', '', dflt)
|
|
73
166
|
entry.name = device.get('name', '')
|
|
@@ -135,45 +228,88 @@ class DeviceInfo:
|
|
|
135
228
|
verify_prefix = '✓ '
|
|
136
229
|
elif verify_status == 'fail':
|
|
137
230
|
verify_prefix = '✗ '
|
|
138
|
-
|
|
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}'
|
|
139
239
|
entry.state = state
|
|
140
240
|
entry.dflt = state # Set dflt so merge logic knows this partition has a marker
|
|
141
241
|
|
|
142
242
|
return entry
|
|
143
243
|
|
|
144
|
-
#
|
|
145
|
-
#
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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 {}
|
|
154
264
|
entries = {}
|
|
155
265
|
|
|
156
266
|
# Parse each block device and its properties
|
|
157
267
|
for device in parsed_data['blockdevices']:
|
|
158
268
|
parent = eat_one(device)
|
|
159
|
-
parent.fstype = self.get_device_vendor_model(parent.name)
|
|
160
269
|
entries[parent.name] = parent
|
|
161
270
|
for child in device.get('children', []):
|
|
162
271
|
entry = eat_one(child)
|
|
163
272
|
entries[entry.name] = entry
|
|
164
273
|
entry.parent = parent.name
|
|
165
274
|
parent.minors.append(entry.name)
|
|
166
|
-
if not parent.fstype:
|
|
167
|
-
parent.fstype = 'DISK'
|
|
168
275
|
self.disk_majors.add(entry.major)
|
|
169
276
|
if entry.mounts:
|
|
170
277
|
entry.state = 'Mnt'
|
|
171
278
|
parent.state = 'Mnt'
|
|
172
279
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
280
|
+
|
|
281
|
+
# Final pass: Identify disks, assign ports, and handle superfloppies
|
|
282
|
+
final_entries = {}
|
|
283
|
+
for name, entry in entries.items():
|
|
284
|
+
final_entries[name] = entry
|
|
285
|
+
|
|
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.
|
|
295
|
+
if not entry.minors and (entry.fstype or entry.label or entry.mounts):
|
|
296
|
+
v_key = f"{name}_data"
|
|
297
|
+
v_child = self._make_partition_namespace(entry.major, name, entry.size_bytes, dflt)
|
|
298
|
+
v_child.name = "----"
|
|
299
|
+
v_child.fstype = entry.fstype
|
|
300
|
+
v_child.label = entry.label
|
|
301
|
+
v_child.mounts = entry.mounts
|
|
302
|
+
v_child.parent = name
|
|
303
|
+
|
|
304
|
+
final_entries[v_key] = v_child
|
|
305
|
+
entry.minors.append(v_key)
|
|
306
|
+
|
|
307
|
+
# Clean the hardware row of data-specific strings
|
|
308
|
+
entry.fstype = entry.model if entry.model else 'DISK'
|
|
309
|
+
entry.label = ''
|
|
310
|
+
entry.mounts = []
|
|
311
|
+
|
|
312
|
+
entries = final_entries
|
|
177
313
|
|
|
178
314
|
return entries
|
|
179
315
|
|
|
@@ -199,9 +335,9 @@ class DeviceInfo:
|
|
|
199
335
|
|
|
200
336
|
if to == 'STOP' and not state_in(ns.state, job_states):
|
|
201
337
|
return False
|
|
202
|
-
if to == '
|
|
338
|
+
if to == 'Blk' and not state_in(ns.state, list(ready_states) + ['Mnt']):
|
|
203
339
|
return False
|
|
204
|
-
if to == '
|
|
340
|
+
if to == 'Unbl' and ns.state != 'Blk':
|
|
205
341
|
return False
|
|
206
342
|
|
|
207
343
|
if to and fnmatch(to, '*%'):
|
|
@@ -221,7 +357,7 @@ class DeviceInfo:
|
|
|
221
357
|
# Here we set inferences that block starting jobs
|
|
222
358
|
# -- clearing these states will be done on the device refresh
|
|
223
359
|
if parent and state_in(ns.state, inferred_states):
|
|
224
|
-
if parent.state != '
|
|
360
|
+
if parent.state != 'Blk':
|
|
225
361
|
parent.state = ns.state
|
|
226
362
|
if state_in(ns.state, job_states):
|
|
227
363
|
if parent:
|
|
@@ -230,6 +366,14 @@ class DeviceInfo:
|
|
|
230
366
|
minor.state = 'Busy'
|
|
231
367
|
return True
|
|
232
368
|
|
|
369
|
+
@staticmethod
|
|
370
|
+
def clear_inferred_states(nss):
|
|
371
|
+
"""Clear all inferred states (Busy, Mnt) so they can be re-inferred"""
|
|
372
|
+
inferred_states = ('Busy', 'Mnt')
|
|
373
|
+
for ns in nss.values():
|
|
374
|
+
if ns.state in inferred_states:
|
|
375
|
+
ns.state = ns.dflt
|
|
376
|
+
|
|
233
377
|
@staticmethod
|
|
234
378
|
def set_all_states(nss):
|
|
235
379
|
"""Set every state per linkage inferences"""
|
|
@@ -237,21 +381,40 @@ class DeviceInfo:
|
|
|
237
381
|
DeviceInfo.set_one_state(nss, ns)
|
|
238
382
|
|
|
239
383
|
def get_disk_partitions(self, nss):
|
|
240
|
-
"""Filter
|
|
241
|
-
Keeps: sd*, nvme*, vd*, hd*, mmcblk* (physical and USB drives)
|
|
242
|
-
Excludes: zram*, loop*, dm-*, sr*, ram*
|
|
243
|
-
"""
|
|
244
|
-
# Virtual/pseudo device prefixes to exclude
|
|
245
|
-
exclude_prefixes = ('zram', 'loop', 'dm-', 'sr', 'ram')
|
|
384
|
+
"""Filter to only wipeable physical storage using positive criteria.
|
|
246
385
|
|
|
386
|
+
Keeps devices that:
|
|
387
|
+
- Are type 'disk' or 'part' (from lsblk)
|
|
388
|
+
- Are writable (not read-only)
|
|
389
|
+
- Are real block devices (not virtual)
|
|
390
|
+
|
|
391
|
+
This automatically excludes:
|
|
392
|
+
- Virtual devices (zram, loop, dm-*, etc.)
|
|
393
|
+
- Read-only devices (CD-ROMs, eMMC boot partitions)
|
|
394
|
+
- Special partitions (boot loaders)
|
|
395
|
+
"""
|
|
247
396
|
ok_nss = {}
|
|
248
397
|
for name, ns in nss.items():
|
|
249
398
|
# Must be disk or partition type
|
|
250
399
|
if ns.type not in ('disk', 'part'):
|
|
251
400
|
continue
|
|
401
|
+
if ns.size_bytes <= 0: # not relevant to wiping
|
|
402
|
+
continue
|
|
403
|
+
|
|
404
|
+
# Must be writable (excludes CD-ROMs, eMMC boot partitions, etc.)
|
|
405
|
+
ro_path = f'/sys/class/block/{name}/ro'
|
|
406
|
+
try:
|
|
407
|
+
with open(ro_path, 'r', encoding='utf-8') as f:
|
|
408
|
+
if f.read().strip() != '0':
|
|
409
|
+
continue # Skip read-only devices
|
|
410
|
+
except (FileNotFoundError, Exception):
|
|
411
|
+
# If we can't read ro flag, skip this device to be safe
|
|
412
|
+
continue
|
|
252
413
|
|
|
253
|
-
# Exclude virtual
|
|
254
|
-
|
|
414
|
+
# Exclude common virtual device prefixes as a safety net
|
|
415
|
+
# (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):
|
|
255
418
|
continue
|
|
256
419
|
|
|
257
420
|
# Include this device
|
|
@@ -261,7 +424,7 @@ class DeviceInfo:
|
|
|
261
424
|
|
|
262
425
|
def compute_field_widths(self, nss):
|
|
263
426
|
"""Compute field widths for display formatting"""
|
|
264
|
-
wids = self.wids
|
|
427
|
+
wids = self.wids
|
|
265
428
|
for ns in nss.values():
|
|
266
429
|
wids.state = max(wids.state, len(ns.state))
|
|
267
430
|
wids.name = max(wids.name, len(ns.name) + 2)
|
|
@@ -270,9 +433,6 @@ class DeviceInfo:
|
|
|
270
433
|
wids.label = max(wids.label, len(ns.label))
|
|
271
434
|
wids.fstype = max(wids.fstype, len(ns.fstype))
|
|
272
435
|
self.head_str = self.get_head_str()
|
|
273
|
-
if self.DB:
|
|
274
|
-
print('\n\nDB: --->>> after compute_field_widths():')
|
|
275
|
-
print(f'self.wids={vars(wids)}')
|
|
276
436
|
|
|
277
437
|
def get_head_str(self):
|
|
278
438
|
"""Generate header string for device list"""
|
|
@@ -357,8 +517,8 @@ class DeviceInfo:
|
|
|
357
517
|
# Check for newly inserted flag first (hot-swapped devices should always show orange)
|
|
358
518
|
if getattr(ns, 'newly_inserted', False):
|
|
359
519
|
# Newly inserted device - orange/bright
|
|
360
|
-
if ns.state in ('Mnt', '
|
|
361
|
-
# Dim the orange for mounted/
|
|
520
|
+
if ns.state in ('Mnt', 'Blk'):
|
|
521
|
+
# Dim the orange for mounted/blocked devices
|
|
362
522
|
attr = curses.color_pair(Theme.HOTSWAP) | curses.A_DIM
|
|
363
523
|
else:
|
|
364
524
|
attr = curses.color_pair(Theme.HOTSWAP) | curses.A_BOLD
|
|
@@ -377,8 +537,8 @@ class DeviceInfo:
|
|
|
377
537
|
elif ns.state == '^':
|
|
378
538
|
# Newly inserted device (hot-swapped) - orange/bright
|
|
379
539
|
attr = curses.color_pair(Theme.HOTSWAP) | curses.A_BOLD
|
|
380
|
-
elif ns.state in ('Mnt', '
|
|
381
|
-
# Dim mounted or
|
|
540
|
+
elif ns.state in ('Mnt', 'Blk'):
|
|
541
|
+
# Dim mounted or blocked devices
|
|
382
542
|
attr = curses.A_DIM
|
|
383
543
|
|
|
384
544
|
# Override with red/danger color if verify failed
|
|
@@ -405,6 +565,7 @@ class DeviceInfo:
|
|
|
405
565
|
if new_ns:
|
|
406
566
|
if prev_ns.job:
|
|
407
567
|
new_ns.job = prev_ns.job
|
|
568
|
+
# Note: Do NOT preserve port - use fresh value from current scan
|
|
408
569
|
new_ns.dflt = prev_ns.dflt
|
|
409
570
|
# Preserve the "wiped this session" flag
|
|
410
571
|
if hasattr(prev_ns, 'wiped_this_session'):
|
|
@@ -443,12 +604,12 @@ class DeviceInfo:
|
|
|
443
604
|
new_ns.verify_failed_msg = prev_ns.verify_failed_msg
|
|
444
605
|
new_ns.mounts = [prev_ns.verify_failed_msg]
|
|
445
606
|
|
|
446
|
-
if prev_ns.state == '
|
|
447
|
-
new_ns.state = '
|
|
607
|
+
if prev_ns.state == 'Blk':
|
|
608
|
+
new_ns.state = 'Blk'
|
|
448
609
|
elif new_ns.state not in ('s', 'W'):
|
|
449
610
|
new_ns.state = new_ns.dflt
|
|
450
611
|
# Don't copy forward percentage states (like "v96%") - only persistent states
|
|
451
|
-
if prev_ns.state not in ('s', 'W', 'Busy', '
|
|
612
|
+
if prev_ns.state not in ('s', 'W', 'Busy', 'Unbl') and not prev_ns.state.endswith('%'):
|
|
452
613
|
new_ns.state = prev_ns.state # re-infer these
|
|
453
614
|
elif prev_ns.job:
|
|
454
615
|
# unplugged device with job..
|
|
@@ -463,30 +624,101 @@ class DeviceInfo:
|
|
|
463
624
|
new_ns.newly_inserted = True # Mark for orange color even if locked/mounted
|
|
464
625
|
return nss
|
|
465
626
|
|
|
466
|
-
def assemble_partitions(self, prev_nss=None):
|
|
467
|
-
"""Assemble and filter partitions for display
|
|
468
|
-
|
|
627
|
+
def assemble_partitions(self, prev_nss=None, lsblk_output=None):
|
|
628
|
+
"""Assemble and filter partitions for display
|
|
629
|
+
|
|
630
|
+
Args:
|
|
631
|
+
prev_nss: Previous device namespaces for merging
|
|
632
|
+
lsblk_output: Optional lsblk JSON output string from LsblkMonitor
|
|
633
|
+
"""
|
|
634
|
+
nss = self.parse_lsblk(dflt='^' if prev_nss else '-', prev_nss=prev_nss,
|
|
635
|
+
lsblk_output=lsblk_output)
|
|
636
|
+
|
|
637
|
+
# If parse_lsblk failed (returned empty) and we have previous data, keep previous state
|
|
638
|
+
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
|
|
641
|
+
# But clear temporary status messages from completed jobs
|
|
642
|
+
for ns in prev_nss.values():
|
|
643
|
+
if not ns.job and ns.mounts:
|
|
644
|
+
# Job finished - clear temporary status messages like "Verified: zeroed"
|
|
645
|
+
ns.mounts = [m for m in ns.mounts if not m.startswith(('Verified:', 'Stopped'))]
|
|
646
|
+
return prev_nss # Return early - don't reprocess
|
|
469
647
|
|
|
470
648
|
nss = self.get_disk_partitions(nss)
|
|
471
649
|
|
|
472
650
|
nss = self.merge_dev_infos(nss, prev_nss)
|
|
473
651
|
|
|
474
|
-
# Apply persistent
|
|
652
|
+
# Apply persistent blocked states
|
|
475
653
|
if self.persistent_state:
|
|
476
654
|
for ns in nss.values():
|
|
477
655
|
# Update last_seen timestamp
|
|
478
656
|
self.persistent_state.update_device_seen(ns)
|
|
479
|
-
# Apply persistent
|
|
657
|
+
# Apply persistent block state
|
|
480
658
|
if self.persistent_state.get_device_locked(ns):
|
|
481
|
-
ns.state = '
|
|
659
|
+
ns.state = 'Blk'
|
|
482
660
|
|
|
661
|
+
# Clear inferred states so they can be re-computed based on current job status
|
|
662
|
+
self.clear_inferred_states(nss)
|
|
483
663
|
self.set_all_states(nss) # set inferred states
|
|
484
664
|
|
|
485
665
|
self.compute_field_widths(nss)
|
|
486
|
-
|
|
487
|
-
if self.DB:
|
|
488
|
-
print('\n\nDB: --->>> after assemble_partitions():')
|
|
489
|
-
for name, ns in nss.items():
|
|
490
|
-
print(f'DB: {name}: {vars(ns)}')
|
|
491
|
-
self.partitions = nss
|
|
492
666
|
return nss
|
|
667
|
+
|
|
668
|
+
@staticmethod
|
|
669
|
+
def dump(parts=None, title='after lsblk'):
|
|
670
|
+
"""Print nicely formatted device information"""
|
|
671
|
+
if not parts:
|
|
672
|
+
return
|
|
673
|
+
|
|
674
|
+
print(f'\n{"=" * 80}')
|
|
675
|
+
print(f'{title}')
|
|
676
|
+
print(f'{"=" * 80}\n')
|
|
677
|
+
|
|
678
|
+
# Separate disks and partitions
|
|
679
|
+
disks = {name: part for name, part in parts.items() if part.type == 'disk'}
|
|
680
|
+
partitions = {name: part for name, part in parts.items() if part.type == 'part'}
|
|
681
|
+
|
|
682
|
+
# Print each disk with its partitions
|
|
683
|
+
for disk_name in sorted(disks.keys()):
|
|
684
|
+
disk = disks[disk_name]
|
|
685
|
+
|
|
686
|
+
# Disk header
|
|
687
|
+
print(f'┌─ {disk.name} ({disk.model or "Unknown Model"})')
|
|
688
|
+
print(f'│ Size: {Utils.human(disk.size_bytes)} Serial: {disk.serial or "N/A"} Port: {disk.port or "N/A"}')
|
|
689
|
+
print(f'│ State: {disk.state} Marker: {disk.marker or "(none)"}')
|
|
690
|
+
|
|
691
|
+
# Hardware capabilities
|
|
692
|
+
if disk.hw_caps:
|
|
693
|
+
caps = ', '.join(disk.hw_caps.keys())
|
|
694
|
+
print(f'│ Hardware: {caps}')
|
|
695
|
+
|
|
696
|
+
# Find and print partitions for this disk
|
|
697
|
+
disk_parts = [(name, part) for name, part in partitions.items()
|
|
698
|
+
if part.parent == disk.name]
|
|
699
|
+
|
|
700
|
+
if disk_parts:
|
|
701
|
+
for i, (part_name, part) in enumerate(sorted(disk_parts)):
|
|
702
|
+
is_last = (i == len(disk_parts) - 1)
|
|
703
|
+
branch = '└─' if is_last else '├─'
|
|
704
|
+
|
|
705
|
+
# Partition info
|
|
706
|
+
label_str = f'"{part.label}"' if part.label else '(no label)'
|
|
707
|
+
fstype_str = part.fstype or '(no filesystem)'
|
|
708
|
+
|
|
709
|
+
print(f'│ {branch} {part.name}: {Utils.human(part.size_bytes)}')
|
|
710
|
+
print(f'│ {" " if is_last else "│ "} Label: {label_str} Type: {fstype_str}')
|
|
711
|
+
print(f'│ {" " if is_last else "│ "} State: {part.state} UUID: {part.uuid or "N/A"}')
|
|
712
|
+
|
|
713
|
+
if part.marker:
|
|
714
|
+
print(f'│ {" " if is_last else "│ "} Marker: {part.marker}')
|
|
715
|
+
|
|
716
|
+
if part.mounts:
|
|
717
|
+
mounts_str = ', '.join(part.mounts)
|
|
718
|
+
print(f'│ {" " if is_last else "│ "} Mounted: {mounts_str}')
|
|
719
|
+
else:
|
|
720
|
+
print(f'│ └─ (no partitions)')
|
|
721
|
+
|
|
722
|
+
print('│')
|
|
723
|
+
|
|
724
|
+
print(f'{"=" * 80}\n')
|