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/DeviceChangeMonitor.py +244 -0
- dwipe/DeviceInfo.py +703 -177
- dwipe/DeviceWorker.py +566 -0
- dwipe/DiskWipe.py +953 -214
- dwipe/DrivePreChecker.py +203 -0
- dwipe/FirmwareWipeTask.py +865 -0
- dwipe/NvmeTool.py +225 -0
- dwipe/PersistentState.py +45 -16
- dwipe/Prereqs.py +84 -0
- dwipe/SataTool.py +499 -0
- dwipe/StructuredLogger.py +644 -0
- dwipe/Tunables.py +62 -0
- dwipe/Utils.py +298 -3
- dwipe/VerifyTask.py +412 -0
- dwipe/WipeJob.py +631 -171
- dwipe/WipeTask.py +150 -0
- dwipe/WriteTask.py +402 -0
- dwipe/main.py +34 -9
- dwipe-3.0.0.dist-info/METADATA +566 -0
- dwipe-3.0.0.dist-info/RECORD +24 -0
- dwipe/ToolManager.py +0 -637
- dwipe/WipeJobFuture.py +0 -245
- dwipe-2.0.1.dist-info/METADATA +0 -410
- dwipe-2.0.1.dist-info/RECORD +0 -14
- {dwipe-2.0.1.dist-info → dwipe-3.0.0.dist-info}/WHEEL +0 -0
- {dwipe-2.0.1.dist-info → dwipe-3.0.0.dist-info}/entry_points.txt +0 -0
- {dwipe-2.0.1.dist-info → dwipe-3.0.0.dist-info}/licenses/LICENSE +0 -0
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
|
|
17
|
-
from .
|
|
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
|
-
|
|
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.
|
|
27
|
-
self.wids =
|
|
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='', #
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if
|
|
166
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
229
|
-
if entry.parent is None:
|
|
230
|
-
#
|
|
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
|
|
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
|
-
|
|
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 == '
|
|
653
|
+
if to == 'Blk' and not state_in(ns.state, list(ready_states) + ['Mnt', 'iMnt', 'iBlk']):
|
|
286
654
|
return False
|
|
287
|
-
if to == '
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
354
|
-
if any(name.startswith(prefix) for prefix in
|
|
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
|
|
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
|
-
#
|
|
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 -
|
|
437
|
-
|
|
438
|
-
|
|
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
|
|
446
|
-
emit += '
|
|
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', '
|
|
461
|
-
# Dim the orange for mounted/
|
|
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 ('
|
|
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', '
|
|
481
|
-
# Dim mounted or
|
|
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
|
-
#
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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 == '
|
|
548
|
-
new_ns.state = '
|
|
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
|
|
552
|
-
if prev_ns.state not in ('s', 'W', 'Busy', '
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
1047
|
+
# Apply persistent block state
|
|
581
1048
|
if self.persistent_state.get_device_locked(ns):
|
|
582
|
-
ns.state = '
|
|
1049
|
+
ns.state = 'Blk'
|
|
583
1050
|
|
|
584
|
-
|
|
1051
|
+
# Clear inferred states so they can be re-computed based on current job status
|
|
1052
|
+
self.clear_inferred_states(nss)
|
|
585
1053
|
|
|
586
|
-
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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')
|