dwipe 2.0.2__py3-none-any.whl → 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dwipe/DeviceChangeMonitor.py +244 -0
- dwipe/DeviceInfo.py +589 -194
- dwipe/DeviceWorker.py +566 -0
- dwipe/DiskWipe.py +558 -134
- dwipe/DrivePreChecker.py +161 -48
- dwipe/FirmwareWipeTask.py +627 -132
- dwipe/NvmeTool.py +225 -0
- dwipe/PersistentState.py +20 -9
- dwipe/SataTool.py +499 -0
- dwipe/StructuredLogger.py +4 -3
- dwipe/Tunables.py +62 -0
- dwipe/Utils.py +192 -5
- dwipe/VerifyTask.py +4 -2
- dwipe/WipeJob.py +25 -13
- dwipe/WipeTask.py +4 -2
- dwipe/WriteTask.py +1 -1
- dwipe/main.py +28 -8
- {dwipe-2.0.2.dist-info → dwipe-3.0.0.dist-info}/METADATA +219 -99
- dwipe-3.0.0.dist-info/RECORD +24 -0
- dwipe/LsblkMonitor.py +0 -124
- dwipe/ToolManager.py +0 -618
- dwipe-2.0.2.dist-info/RECORD +0 -21
- {dwipe-2.0.2.dist-info → dwipe-3.0.0.dist-info}/WHEEL +0 -0
- {dwipe-2.0.2.dist-info → dwipe-3.0.0.dist-info}/entry_points.txt +0 -0
- {dwipe-2.0.2.dist-info → dwipe-3.0.0.dist-info}/licenses/LICENSE +0 -0
dwipe/DiskWipe.py
CHANGED
|
@@ -15,16 +15,18 @@ import threading
|
|
|
15
15
|
import json
|
|
16
16
|
import curses as cs
|
|
17
17
|
from types import SimpleNamespace
|
|
18
|
+
from datetime import datetime
|
|
18
19
|
from console_window import (ConsoleWindow, ConsoleWindowOpts, OptionSpinner,
|
|
19
20
|
IncrementalSearchBar, InlineConfirmation, Theme,
|
|
20
21
|
Screen, ScreenStack, Context)
|
|
21
22
|
|
|
22
23
|
from .WipeJob import WipeJob
|
|
23
24
|
from .DeviceInfo import DeviceInfo
|
|
25
|
+
from .DeviceWorker import ProbeState
|
|
24
26
|
from .Utils import Utils
|
|
25
27
|
from .PersistentState import PersistentState
|
|
26
28
|
from .StructuredLogger import StructuredLogger
|
|
27
|
-
from .
|
|
29
|
+
from .DeviceChangeMonitor import DeviceChangeMonitor
|
|
28
30
|
|
|
29
31
|
# Screen constants
|
|
30
32
|
MAIN_ST = 0
|
|
@@ -38,9 +40,26 @@ class DiskWipe:
|
|
|
38
40
|
"""Main application controller and UI manager"""
|
|
39
41
|
singleton = None
|
|
40
42
|
|
|
41
|
-
def __init__(self, opts=None):
|
|
43
|
+
def __init__(self, opts=None, persistent_state=None):
|
|
42
44
|
DiskWipe.singleton = self
|
|
43
45
|
self.opts = opts if opts else SimpleNamespace(debug=0)
|
|
46
|
+
# Use provided persistent_state or create a new one
|
|
47
|
+
self.persistent_state = persistent_state if persistent_state else PersistentState()
|
|
48
|
+
# Set defaults for command-line options (only if not provided)
|
|
49
|
+
if not hasattr(self.opts, 'wipe_mode'):
|
|
50
|
+
self.opts.wipe_mode = '+V'
|
|
51
|
+
if not hasattr(self.opts, 'passes'):
|
|
52
|
+
self.opts.passes = 1
|
|
53
|
+
if not hasattr(self.opts, 'verify_pct'):
|
|
54
|
+
self.opts.verify_pct = 1
|
|
55
|
+
if not hasattr(self.opts, 'port_serial'):
|
|
56
|
+
self.opts.port_serial = 'Auto'
|
|
57
|
+
if not hasattr(self.opts, 'slowdown_stop'):
|
|
58
|
+
self.opts.slowdown_stop = 64
|
|
59
|
+
if not hasattr(self.opts, 'stall_timeout'):
|
|
60
|
+
self.opts.stall_timeout = 60
|
|
61
|
+
if not hasattr(self.opts, 'dense'):
|
|
62
|
+
self.opts.dense = False
|
|
44
63
|
self.mounts_lines = None
|
|
45
64
|
self.partitions = {} # a dict of namespaces keyed by name
|
|
46
65
|
self.wids = None
|
|
@@ -69,9 +88,6 @@ class DiskWipe:
|
|
|
69
88
|
on_cancel=self._on_filter_cancel
|
|
70
89
|
)
|
|
71
90
|
|
|
72
|
-
# Initialize persistent state
|
|
73
|
-
self.persistent_state = PersistentState()
|
|
74
|
-
|
|
75
91
|
def _start_wipe(self):
|
|
76
92
|
"""Start the wipe job after confirmation"""
|
|
77
93
|
if self.confirmation.identity and self.confirmation.identity in self.partitions:
|
|
@@ -81,7 +97,8 @@ class DiskWipe:
|
|
|
81
97
|
delattr(part, 'verify_failed_msg')
|
|
82
98
|
|
|
83
99
|
# Get the wipe type from user's choice
|
|
84
|
-
|
|
100
|
+
# ConsoleWindow already canonicalizes case, just strip the '*' recommendation marker
|
|
101
|
+
wipe_type = self.confirmation.input_buffer.strip().rstrip('*')
|
|
85
102
|
|
|
86
103
|
# Check if it's a firmware wipe
|
|
87
104
|
if wipe_type not in ('Zero', 'Rand'):
|
|
@@ -92,11 +109,14 @@ class DiskWipe:
|
|
|
92
109
|
self.win.passthrough_mode = False
|
|
93
110
|
return
|
|
94
111
|
|
|
95
|
-
# Get command args
|
|
96
|
-
|
|
112
|
+
# Get command args for this wipe type
|
|
113
|
+
from .DrivePreChecker import DrivePreChecker
|
|
114
|
+
command_args = DrivePreChecker.get_wipe_command_args(wipe_type)
|
|
97
115
|
|
|
98
116
|
# Import firmware task classes
|
|
99
|
-
from .FirmwareWipeTask import NvmeWipeTask, SataWipeTask
|
|
117
|
+
from .FirmwareWipeTask import (NvmeWipeTask, SataWipeTask,
|
|
118
|
+
StandardPrecheckTask,
|
|
119
|
+
FirmwarePreVerifyTask, FirmwarePostVerifyTask)
|
|
100
120
|
|
|
101
121
|
# Determine task type based on device name
|
|
102
122
|
if part.name.startswith('nvme'):
|
|
@@ -105,7 +125,7 @@ class DiskWipe:
|
|
|
105
125
|
task_class = SataWipeTask
|
|
106
126
|
|
|
107
127
|
# Create firmware task
|
|
108
|
-
|
|
128
|
+
fw_task = task_class(
|
|
109
129
|
device_path=f'/dev/{part.name}',
|
|
110
130
|
total_size=part.size_bytes,
|
|
111
131
|
opts=self.opts,
|
|
@@ -116,13 +136,39 @@ class DiskWipe:
|
|
|
116
136
|
# Store wipe type for logging
|
|
117
137
|
part.wipe_type = wipe_type
|
|
118
138
|
|
|
119
|
-
#
|
|
139
|
+
# Build task list with precheck, pre/post firmware verification
|
|
140
|
+
# (Software verify is not supported for firmware wipes)
|
|
141
|
+
precheck = StandardPrecheckTask(
|
|
142
|
+
device_path=f'/dev/{part.name}',
|
|
143
|
+
total_size=part.size_bytes,
|
|
144
|
+
opts=self.opts,
|
|
145
|
+
selected_wipe_type=wipe_type,
|
|
146
|
+
command_method=command_args
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
pre_verify = FirmwarePreVerifyTask(
|
|
150
|
+
device_path=f'/dev/{part.name}',
|
|
151
|
+
total_size=part.size_bytes,
|
|
152
|
+
opts=self.opts
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
post_verify = FirmwarePostVerifyTask(
|
|
156
|
+
device_path=f'/dev/{part.name}',
|
|
157
|
+
total_size=part.size_bytes,
|
|
158
|
+
opts=self.opts
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
tasks = [precheck, pre_verify, fw_task, post_verify]
|
|
162
|
+
|
|
163
|
+
# Create WipeJob with firmware task (and optional verify task)
|
|
120
164
|
part.job = WipeJob(
|
|
121
165
|
device_path=f'/dev/{part.name}',
|
|
122
166
|
total_size=part.size_bytes,
|
|
123
167
|
opts=self.opts,
|
|
124
|
-
tasks=
|
|
168
|
+
tasks=tasks
|
|
125
169
|
)
|
|
170
|
+
# Firmware wipes (SATA secure erase, NVMe sanitize) write zeros
|
|
171
|
+
part.job.expected_pattern = "zeroed"
|
|
126
172
|
part.job.thread = threading.Thread(target=part.job.run_tasks)
|
|
127
173
|
part.job.thread.start()
|
|
128
174
|
|
|
@@ -152,6 +198,7 @@ class DiskWipe:
|
|
|
152
198
|
part.size_bytes, opts=self.opts)
|
|
153
199
|
self.job_cnt += 1
|
|
154
200
|
self.set_state(part, to='0%')
|
|
201
|
+
part.hw_nopes, part.hw_caps = {}, {}
|
|
155
202
|
finally:
|
|
156
203
|
# Restore original wipe_mode
|
|
157
204
|
self.opts.wipe_mode = old_wipe_mode
|
|
@@ -164,6 +211,14 @@ class DiskWipe:
|
|
|
164
211
|
"""Start the verify job after confirmation"""
|
|
165
212
|
if self.confirmation.identity and self.confirmation.identity in self.partitions:
|
|
166
213
|
part = self.partitions[self.confirmation.identity]
|
|
214
|
+
|
|
215
|
+
# Safety check: Firmware wipes have built-in verification - prevent manual verify
|
|
216
|
+
if self._is_firmware_wipe_marker(part):
|
|
217
|
+
part.mounts = ['⚠ Firmware wipes have built-in verification - standard verify not allowed']
|
|
218
|
+
self.confirmation.cancel()
|
|
219
|
+
self.win.passthrough_mode = False
|
|
220
|
+
return
|
|
221
|
+
|
|
167
222
|
# Clear any previous verify failure message when starting verify
|
|
168
223
|
if hasattr(part, 'verify_failed_msg'):
|
|
169
224
|
delattr(part, 'verify_failed_msg')
|
|
@@ -188,6 +243,38 @@ class DiskWipe:
|
|
|
188
243
|
|
|
189
244
|
return result
|
|
190
245
|
|
|
246
|
+
def _is_firmware_wipe(self, part):
|
|
247
|
+
"""Check if partition has an active firmware wipe job (unstoppable)."""
|
|
248
|
+
if not part.job or part.job.done:
|
|
249
|
+
return False
|
|
250
|
+
from .FirmwareWipeTask import FirmwareWipeTask
|
|
251
|
+
current_task = getattr(part.job, 'current_task', None)
|
|
252
|
+
return current_task and isinstance(current_task, FirmwareWipeTask)
|
|
253
|
+
|
|
254
|
+
def _is_firmware_wipe_marker(self, part):
|
|
255
|
+
"""Check if partition's wipe marker indicates a firmware wipe.
|
|
256
|
+
|
|
257
|
+
Firmware wipes have modes like 'Crypto', 'Enhanced', 'Sanitize-Crypto', etc.
|
|
258
|
+
Software wipes have modes like 'Zero' or 'Rand'.
|
|
259
|
+
"""
|
|
260
|
+
if part.state not in ('s', 'W'):
|
|
261
|
+
return False
|
|
262
|
+
|
|
263
|
+
marker = WipeJob.read_marker_buffer(part.name)
|
|
264
|
+
if not marker:
|
|
265
|
+
return False
|
|
266
|
+
|
|
267
|
+
# Check if mode is a firmware wipe (not 'Zero' or 'Rand')
|
|
268
|
+
mode = getattr(marker, 'mode', None)
|
|
269
|
+
return mode and mode not in ('Zero', 'Rand')
|
|
270
|
+
|
|
271
|
+
def _has_any_firmware_wipes(self):
|
|
272
|
+
"""Check if any firmware wipes are currently running."""
|
|
273
|
+
for part in self.partitions.values():
|
|
274
|
+
if self._is_firmware_wipe(part):
|
|
275
|
+
return True
|
|
276
|
+
return False
|
|
277
|
+
|
|
191
278
|
def do_key(self, key):
|
|
192
279
|
"""Handle keyboard input"""
|
|
193
280
|
if self.exit_when_no_jobs:
|
|
@@ -253,15 +340,7 @@ class DiskWipe:
|
|
|
253
340
|
line += ' [S]top' if self.job_cnt > 0 else ''
|
|
254
341
|
line = f'{line:<20} '
|
|
255
342
|
line += self.filter_bar.get_display_string(prefix=' /') or ' /'
|
|
256
|
-
|
|
257
|
-
line += f' [m]ode={self.opts.wipe_mode}'
|
|
258
|
-
# Show passes spinner with key
|
|
259
|
-
line += f' [P]ass={self.opts.passes}'
|
|
260
|
-
# Show verification percentage spinner with key
|
|
261
|
-
line += f' [V]pct={self.opts.verify_pct}%'
|
|
262
|
-
line += f' [p]ort={self.opts.port_serial}'
|
|
263
|
-
# line += ' !:scan [h]ist [t]heme ?:help [q]uit'
|
|
264
|
-
line += ' [h]ist [t]heme ?:help [q]uit'
|
|
343
|
+
line += ' [r]escan [h]ist [t]heme ?:help [q]uit'
|
|
265
344
|
return line[1:]
|
|
266
345
|
|
|
267
346
|
def get_actions(self, part):
|
|
@@ -272,22 +351,27 @@ class DiskWipe:
|
|
|
272
351
|
part = ctx.partition
|
|
273
352
|
name = part.name
|
|
274
353
|
self.pick_is_running = bool(part.job)
|
|
275
|
-
|
|
354
|
+
# Show stop action only for non-firmware wipes
|
|
355
|
+
if self.test_state(part, to='STOP') and not self._is_firmware_wipe(part):
|
|
276
356
|
actions['s'] = 'stop'
|
|
277
357
|
elif self.test_state(part, to='0%'):
|
|
278
358
|
actions['w'] = 'wipe'
|
|
279
|
-
|
|
359
|
+
# DEL only for whole disks that are SATA or NVMe
|
|
360
|
+
if part.parent is None and part.name[:2] in ('sd', 'hd', 'nv'):
|
|
280
361
|
actions['DEL'] = 'DEL'
|
|
281
362
|
# Can verify:
|
|
282
|
-
# 1. Anything with wipe markers (states 's' or 'W')
|
|
363
|
+
# 1. Anything with wipe markers (states 's' or 'W') - EXCEPT firmware wipes
|
|
283
364
|
# 2. Unmarked whole disks (no parent, state '-' or '^') WITHOUT partitions that have filesystems
|
|
284
365
|
# 3. Unmarked partitions without filesystems (has parent, state '-' or '^', no fstype)
|
|
285
366
|
# 4. Only if verify_pct > 0
|
|
286
367
|
# This prevents verifying filesystems which is nonsensical
|
|
368
|
+
# NOTE: Firmware wipes have built-in pre/post verification, so manual verify is disallowed
|
|
287
369
|
verify_pct = getattr(self.opts, 'verify_pct', 0)
|
|
288
370
|
if not part.job and verify_pct > 0:
|
|
289
371
|
if part.state in ('s', 'W'):
|
|
290
|
-
|
|
372
|
+
# Do NOT allow verify on firmware wipes - they have built-in verification
|
|
373
|
+
if not self._is_firmware_wipe_marker(part):
|
|
374
|
+
actions['v'] = 'verify'
|
|
291
375
|
elif part.state in ('-', '^'):
|
|
292
376
|
# For whole disks (no parent), only allow verify if no partitions have filesystems
|
|
293
377
|
# For partitions, only allow if no filesystem
|
|
@@ -344,19 +428,48 @@ class DiskWipe:
|
|
|
344
428
|
""" Look for wipeable disks w/o hardware info """
|
|
345
429
|
if not self.dev_info:
|
|
346
430
|
return
|
|
431
|
+
from .DeviceWorker import ProbeState
|
|
347
432
|
for ns in self.partitions.values():
|
|
348
433
|
if ns.parent:
|
|
349
434
|
continue
|
|
350
|
-
|
|
351
|
-
|
|
435
|
+
# if ns.port.startswith('USB'):
|
|
436
|
+
# continue
|
|
352
437
|
if ns.name[:2] not in ('nv', 'sd', 'hd'):
|
|
353
438
|
continue
|
|
354
|
-
if
|
|
439
|
+
# Skip if already successfully probed (READY state) with actual results
|
|
440
|
+
# But re-probe if results are empty (unknown after wipe) or if probe FAILED
|
|
441
|
+
if ns.hw_caps_state == ProbeState.READY and (ns.hw_caps or ns.hw_nopes):
|
|
442
|
+
continue # Already have results
|
|
443
|
+
# Device must not be actively wiping (to avoid blocking)
|
|
444
|
+
if ns.job:
|
|
355
445
|
continue
|
|
356
|
-
|
|
446
|
+
# Don't probe mounted/blocked devices
|
|
447
|
+
if ns.state in ('Mnt', 'iMnt', 'Blk', 'iBlk'):
|
|
448
|
+
continue
|
|
449
|
+
# Reset state to PENDING to force a re-probe if needed
|
|
450
|
+
# (state stays READY even after wipe clears results)
|
|
451
|
+
if not (ns.hw_caps or ns.hw_nopes):
|
|
452
|
+
ns.hw_caps_state = ProbeState.PENDING
|
|
453
|
+
# Probe any device that might be ready (s, W, -, ^, or wipeable state)
|
|
454
|
+
self.dev_info.get_hw_capabilities(ns)
|
|
455
|
+
|
|
456
|
+
def _poll_hw_caps_updates(self):
|
|
457
|
+
"""Poll for hw_caps probe completion without full device refresh.
|
|
458
|
+
|
|
459
|
+
Called every 0.25 seconds in main loop to quickly show hw_caps
|
|
460
|
+
results as soon as worker threads complete probes.
|
|
461
|
+
"""
|
|
462
|
+
if not self.dev_info:
|
|
463
|
+
return
|
|
464
|
+
from .DeviceWorker import ProbeState
|
|
465
|
+
# Only update devices that are still probing
|
|
466
|
+
for ns in self.partitions.values():
|
|
467
|
+
if ns.parent:
|
|
468
|
+
continue
|
|
469
|
+
# Update any device that's still PENDING or PROBING
|
|
470
|
+
if ns.hw_caps_state in (ProbeState.PENDING, ProbeState.PROBING):
|
|
357
471
|
self.dev_info.get_hw_capabilities(ns)
|
|
358
472
|
|
|
359
|
-
|
|
360
473
|
def main_loop(self):
|
|
361
474
|
"""Main event loop"""
|
|
362
475
|
|
|
@@ -379,22 +492,24 @@ class DiskWipe:
|
|
|
379
492
|
pick_attr=cs.A_REVERSE, # Use reverse video for pick highlighting
|
|
380
493
|
ctrl_c_terminates=False,
|
|
381
494
|
)
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
print("
|
|
495
|
+
device_monitor = DeviceChangeMonitor(check_interval=1.0)
|
|
496
|
+
device_monitor.start()
|
|
497
|
+
print("Discovering devices...")
|
|
498
|
+
# Create persistent worker manager for hw_caps probing
|
|
499
|
+
# This is reused across device refreshes to allow probes to complete
|
|
500
|
+
from .DeviceWorker import DeviceWorkerManager
|
|
501
|
+
from .DrivePreChecker import DrivePreChecker
|
|
502
|
+
worker_manager = DeviceWorkerManager(DrivePreChecker())
|
|
385
503
|
# Initialize device info and pick range before first draw
|
|
386
|
-
info = DeviceInfo(opts=self.opts, persistent_state=self.persistent_state
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
DeviceInfo.dump(self.partitions, title="after assemble_partitions")
|
|
396
|
-
exit(1)
|
|
397
|
-
time.sleep(0.2)
|
|
504
|
+
info = DeviceInfo(opts=self.opts, persistent_state=self.persistent_state,
|
|
505
|
+
worker_manager=worker_manager)
|
|
506
|
+
self.partitions = info.assemble_partitions(self.partitions)
|
|
507
|
+
# Start probing hw_caps immediately instead of waiting for first 3s refresh
|
|
508
|
+
self.dev_info = info
|
|
509
|
+
self.get_hw_caps_when_needed()
|
|
510
|
+
if self.opts.dump_lsblk:
|
|
511
|
+
DeviceInfo.dump(self.partitions, title="after assemble_partitions")
|
|
512
|
+
exit(1)
|
|
398
513
|
|
|
399
514
|
self.win = ConsoleWindow(opts=win_opts)
|
|
400
515
|
# Initialize screen stack
|
|
@@ -403,12 +518,8 @@ class DiskWipe:
|
|
|
403
518
|
spin = self.spin = OptionSpinner(stack=self.stack)
|
|
404
519
|
spin.default_obj = self.opts
|
|
405
520
|
spin.add_key('dense', 'D - dense/spaced view', vals=[False, True])
|
|
406
|
-
spin.add_key('
|
|
407
|
-
|
|
408
|
-
spin.add_key('stall_timeout', 'T - stall timeout (sec)', vals=[60, 120, 300, 600, 0,])
|
|
409
|
-
spin.add_key('verify_pct', 'V - verification %', vals=[0, 2, 5, 10, 25, 50, 100])
|
|
410
|
-
spin.add_key('passes', 'P - wipe pass count', vals=[1, 2, 4])
|
|
411
|
-
spin.add_key('wipe_mode', 'm - wipe mode', vals=['-V', '+V'])
|
|
521
|
+
spin.add_key('hist_time_format', 'a - time format',
|
|
522
|
+
vals=['ago+time', 'ago', 'time'], scope=LOG_ST)
|
|
412
523
|
|
|
413
524
|
spin.add_key('quit', 'q,x - quit program', keys='qx', genre='action')
|
|
414
525
|
spin.add_key('screen_escape', 'ESC- back one screen',
|
|
@@ -419,9 +530,10 @@ class DiskWipe:
|
|
|
419
530
|
spin.add_key('verify', 'v - verify device', genre='action')
|
|
420
531
|
spin.add_key('stop', 's - stop wipe', genre='action')
|
|
421
532
|
spin.add_key('block', 'b - block/unblock disk', genre='action')
|
|
422
|
-
spin.add_key('delete_device', 'DEL - remove disk from
|
|
533
|
+
spin.add_key('delete_device', 'DEL - remove/unbind disk from system',
|
|
423
534
|
genre='action', keys=(cs.KEY_DC))
|
|
424
|
-
spin.add_key('scan_all_devices', '
|
|
535
|
+
spin.add_key('scan_all_devices', 'r - rescan devices and recheck capabilities',
|
|
536
|
+
genre='action', scope=MAIN_ST)
|
|
425
537
|
spin.add_key('stop_all', 'S - stop ALL wipes', genre='action')
|
|
426
538
|
spin.add_key('help', '? - show help screen', genre='action')
|
|
427
539
|
spin.add_key('history', 'h - show wipe history', genre='action')
|
|
@@ -430,21 +542,23 @@ class DiskWipe:
|
|
|
430
542
|
spin.add_key('spin_theme', 't - theme', genre='action', scope=THEME_ST)
|
|
431
543
|
spin.add_key('header_mode', '_ - header style', vals=['Underline', 'Reverse', 'Off'])
|
|
432
544
|
spin.add_key('expand', 'e - expand history entry', genre='action', scope=LOG_ST)
|
|
545
|
+
spin.add_key('copy', 'c - copy entry to clipboard', genre='action', scope=LOG_ST)
|
|
433
546
|
spin.add_key('show_keys', 'K - show keys (demo mode)', genre='action')
|
|
434
|
-
|
|
435
|
-
self.persistent_state.
|
|
547
|
+
# Load theme from persistent state
|
|
548
|
+
self.opts.theme = self.persistent_state.state.get('theme', '')
|
|
436
549
|
Theme.set(self.opts.theme)
|
|
437
550
|
self.win.set_handled_keys(self.spin.keys)
|
|
438
551
|
|
|
439
|
-
#
|
|
552
|
+
# Background device change monitor started above
|
|
440
553
|
|
|
441
|
-
self.
|
|
442
|
-
self.
|
|
554
|
+
# self.dev_info already set during startup probe above
|
|
555
|
+
self.worker_manager = worker_manager
|
|
443
556
|
pick_range = info.get_pick_range()
|
|
444
557
|
self.win.set_pick_range(pick_range[0], pick_range[1])
|
|
445
558
|
|
|
446
559
|
|
|
447
560
|
check_devices_mono = time.monotonic()
|
|
561
|
+
cached_worker_state = {} # Track marker/hw_caps state to detect updates
|
|
448
562
|
|
|
449
563
|
try:
|
|
450
564
|
while True:
|
|
@@ -459,20 +573,44 @@ class DiskWipe:
|
|
|
459
573
|
# Handle actions using perform_actions
|
|
460
574
|
self.stack.perform_actions(spin)
|
|
461
575
|
|
|
462
|
-
#
|
|
463
|
-
|
|
576
|
+
# Poll for hw_caps completion without full device refresh (every 0.25s)
|
|
577
|
+
# This lets us show results quickly even though commands take 1-3 seconds
|
|
578
|
+
self._poll_hw_caps_updates()
|
|
579
|
+
|
|
580
|
+
# Check for device changes from background monitor
|
|
581
|
+
devices_changed = device_monitor.get_and_clear()
|
|
464
582
|
time_since_refresh = time.monotonic() - check_devices_mono
|
|
465
583
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
self.partitions
|
|
470
|
-
|
|
584
|
+
# Build current worker state for comparison
|
|
585
|
+
current_worker_state = {}
|
|
586
|
+
if self.worker_manager:
|
|
587
|
+
for device_name, part in self.partitions.items():
|
|
588
|
+
current_worker_state[device_name] = {
|
|
589
|
+
'marker': part.marker,
|
|
590
|
+
'hw_caps_state': part.hw_caps_state
|
|
591
|
+
}
|
|
592
|
+
# Check if worker has any updates (markers or hw_caps changes)
|
|
593
|
+
worker_has_updates = (
|
|
594
|
+
self.worker_manager.has_updates(cached_worker_state)
|
|
595
|
+
)
|
|
596
|
+
else:
|
|
597
|
+
worker_has_updates = False
|
|
598
|
+
|
|
599
|
+
if (devices_changed or worker_has_updates or
|
|
600
|
+
time_since_refresh > 3.0):
|
|
601
|
+
# Refresh if: device changes, worker updates, OR periodic (3s default)
|
|
602
|
+
info = DeviceInfo(opts=self.opts, persistent_state=self.persistent_state,
|
|
603
|
+
worker_manager=self.worker_manager)
|
|
604
|
+
self.partitions = info.assemble_partitions(self.partitions)
|
|
471
605
|
self.dev_info = info
|
|
472
606
|
# Update pick range to highlight NAME through SIZE fields
|
|
473
607
|
pick_range = info.get_pick_range()
|
|
474
608
|
self.win.set_pick_range(pick_range[0], pick_range[1])
|
|
609
|
+
# Probe hw_caps for devices that need it (only once per refresh, not every draw)
|
|
610
|
+
self.get_hw_caps_when_needed()
|
|
475
611
|
check_devices_mono = time.monotonic()
|
|
612
|
+
# Update cached state after refresh
|
|
613
|
+
cached_worker_state = current_worker_state.copy()
|
|
476
614
|
|
|
477
615
|
# Save any persistent state changes
|
|
478
616
|
self.persistent_state.save_updated_opts(self.opts)
|
|
@@ -481,7 +619,10 @@ class DiskWipe:
|
|
|
481
619
|
self.win.clear()
|
|
482
620
|
finally:
|
|
483
621
|
# Clean up monitor thread on exit
|
|
484
|
-
|
|
622
|
+
device_monitor.stop()
|
|
623
|
+
# Clean up persistent worker manager
|
|
624
|
+
if hasattr(self, 'worker_manager') and self.worker_manager:
|
|
625
|
+
self.worker_manager.stop_all()
|
|
485
626
|
|
|
486
627
|
class DiskWipeScreen(Screen):
|
|
487
628
|
""" TBD """
|
|
@@ -505,35 +646,30 @@ class MainScreen(DiskWipeScreen):
|
|
|
505
646
|
self.persist_port_serial = set()
|
|
506
647
|
|
|
507
648
|
|
|
508
|
-
def _port_serial_line(self, partition):
|
|
649
|
+
def _port_serial_line(self, partition, has_children=True):
|
|
509
650
|
wids = self.app.wids
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
port
|
|
513
|
-
|
|
514
|
-
lead = 'CAPS' if partition.hw_caps else 'ERRS'
|
|
515
|
-
infos = partition.hw_caps if partition.hw_caps else partition.hw_nopes
|
|
516
|
-
key_str = f' Fw{lead}: ' + ','.join(list(infos.keys()))
|
|
517
|
-
return f'{"":>{wid}}{sep}│ └────── {port:<12} {serial}{key_str}'
|
|
651
|
+
sep = ' '
|
|
652
|
+
# Sanitize port/serial - some USB bridges return strings with embedded nulls
|
|
653
|
+
port = partition.port.replace('\x00', '') if partition.port else ''
|
|
654
|
+
serial = partition.serial.replace('\x00', '') if partition.serial else ''
|
|
518
655
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
app = self.app
|
|
656
|
+
# Get column widths (with defaults if wids not yet initialized)
|
|
657
|
+
wid_state = wids.state if wids else 5
|
|
522
658
|
|
|
523
|
-
|
|
524
|
-
|
|
659
|
+
# Use corner └ if no children below, or vertical │ if there are children to connect to
|
|
660
|
+
connector = '│' if has_children else ' '
|
|
525
661
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
self.persist_port_serial = set() # name of disks
|
|
529
|
-
else: # if the disk goes away, clear persistence
|
|
530
|
-
for name in list(self.persist_port_serial):
|
|
531
|
-
if name not in app.partitions:
|
|
532
|
-
self.persist_port_serial.discard(name)
|
|
662
|
+
# Build base line: state padding + connector + port/serial
|
|
663
|
+
base = f'{"":>{wid_state}}{sep}{connector} └────── {port:<12} {serial}'
|
|
533
664
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
665
|
+
return base
|
|
666
|
+
|
|
667
|
+
def do_job_maintenance(self):
|
|
668
|
+
""" Check all the jobs in progress and advance their state
|
|
669
|
+
appropriately.
|
|
670
|
+
"""
|
|
671
|
+
app = self.app
|
|
672
|
+
for _, partition in app.partitions.items():
|
|
537
673
|
partition.line = None
|
|
538
674
|
if partition.job:
|
|
539
675
|
if partition.job.done:
|
|
@@ -629,6 +765,7 @@ class MainScreen(DiskWipeScreen):
|
|
|
629
765
|
partition.job = None
|
|
630
766
|
partition.marker_checked = False # Reset to "dont-know" - will re-read on next scan
|
|
631
767
|
partition.marker = '' # Clear stale marker string to avoid showing old data during re-read
|
|
768
|
+
partition.monitor_marker = False # Stop monitoring verify job
|
|
632
769
|
else:
|
|
633
770
|
# Wipe job completed (with or without auto-verify)
|
|
634
771
|
# Check if stopped during verify phase (after successful write)
|
|
@@ -716,8 +853,9 @@ class MainScreen(DiskWipeScreen):
|
|
|
716
853
|
partition.job = None
|
|
717
854
|
partition.marker_checked = False # Reset to "dont-know" - will re-read on next scan
|
|
718
855
|
partition.marker = '' # Clear stale marker string to avoid showing old data during re-read
|
|
856
|
+
partition.monitor_marker = True # Start monitoring the new marker
|
|
719
857
|
if partition.job:
|
|
720
|
-
elapsed, pct, rate, until = partition.job.get_status()
|
|
858
|
+
elapsed, pct, rate, until, more_state = partition.job.get_status()
|
|
721
859
|
|
|
722
860
|
# Get task display name (Zero, Rand, Crypto, Verify, etc.)
|
|
723
861
|
task_name = ""
|
|
@@ -731,26 +869,76 @@ class MainScreen(DiskWipeScreen):
|
|
|
731
869
|
partition.mounts = [f'{task_name} {pct} {elapsed} -{until} {rate}']
|
|
732
870
|
else:
|
|
733
871
|
partition.mounts = [f'{task_name} {pct} {elapsed}']
|
|
872
|
+
if more_state:
|
|
873
|
+
partition.mounts[0] += f' {more_state}'
|
|
734
874
|
else:
|
|
735
875
|
partition.state = pct
|
|
736
876
|
# Build progress line with task name
|
|
737
877
|
progress_parts = [task_name, elapsed, f'-{until}', rate]
|
|
738
878
|
|
|
739
|
-
# Only show slowdown/stall
|
|
740
|
-
|
|
741
|
-
|
|
879
|
+
# Only show slowdown/stall for WriteTask (not VerifyTask or FirmwareWipeTask)
|
|
880
|
+
from .FirmwareWipeTask import FirmwareWipeTask
|
|
881
|
+
from .WriteTask import WriteTask
|
|
882
|
+
current_task = partition.job.current_task
|
|
883
|
+
if current_task and isinstance(current_task, WriteTask) and not isinstance(current_task, FirmwareWipeTask):
|
|
742
884
|
slowdown = partition.job.max_slowdown_ratio
|
|
743
885
|
stall = partition.job.max_stall_secs
|
|
744
886
|
progress_parts.extend([f'÷{slowdown}', f'𝚫{Utils.ago_str(stall)}'])
|
|
745
887
|
|
|
888
|
+
if more_state:
|
|
889
|
+
progress_parts.append(more_state)
|
|
890
|
+
|
|
746
891
|
partition.mounts = [' '.join(progress_parts)]
|
|
747
892
|
|
|
748
893
|
if partition.parent and partition.parent in app.partitions and (
|
|
749
|
-
app.partitions[partition.parent].state
|
|
894
|
+
app.partitions[partition.parent].state in ('Blk', 'iBlk')):
|
|
750
895
|
continue
|
|
751
896
|
|
|
752
|
-
|
|
753
|
-
|
|
897
|
+
|
|
898
|
+
def draw_screen(self):
|
|
899
|
+
"""Draw the main device list"""
|
|
900
|
+
app = self.app
|
|
901
|
+
|
|
902
|
+
def wanted(name):
|
|
903
|
+
return not app.filter or app.filter.search(name)
|
|
904
|
+
|
|
905
|
+
self.do_job_maintenance()
|
|
906
|
+
|
|
907
|
+
app.win.set_pick_mode(True)
|
|
908
|
+
if app.opts.port_serial != 'Auto':
|
|
909
|
+
self.persist_port_serial = set() # name of disks
|
|
910
|
+
else: # if the disk goes away, clear persistence
|
|
911
|
+
for name in list(self.persist_port_serial):
|
|
912
|
+
if name not in app.partitions:
|
|
913
|
+
self.persist_port_serial.discard(name)
|
|
914
|
+
|
|
915
|
+
# process jobs and collect visible partitions, sorted by disk then partition
|
|
916
|
+
visible_partitions = []
|
|
917
|
+
# Get disks sorted alphabetically
|
|
918
|
+
disks = sorted([p for p in app.partitions.values() if p.parent is None],
|
|
919
|
+
key=lambda p: p.name)
|
|
920
|
+
for disk in disks:
|
|
921
|
+
if wanted(disk.name) or disk.job:
|
|
922
|
+
visible_partitions.append(disk)
|
|
923
|
+
# Add partitions for this disk, sorted alphabetically
|
|
924
|
+
parts = sorted([p for p in app.partitions.values() if p.parent == disk.name],
|
|
925
|
+
key=lambda p: p.name)
|
|
926
|
+
# If disk is directly blocked, hide children and aggregate their mounts
|
|
927
|
+
if disk.state == 'Blk':
|
|
928
|
+
# Collect all mounts from children, sort with "/" first (by name/length)
|
|
929
|
+
all_mounts = []
|
|
930
|
+
for part in parts:
|
|
931
|
+
all_mounts.extend(part.mounts)
|
|
932
|
+
# Sort: "/" first, then by name (implicitly by length since "/" is shortest)
|
|
933
|
+
all_mounts.sort(key=lambda m: (m != '/', m))
|
|
934
|
+
disk.aggregated_mounts = all_mounts
|
|
935
|
+
# Skip adding children to visible list
|
|
936
|
+
continue
|
|
937
|
+
else:
|
|
938
|
+
disk.aggregated_mounts = None
|
|
939
|
+
for part in parts:
|
|
940
|
+
if wanted(part.name) or part.job:
|
|
941
|
+
visible_partitions.append(part)
|
|
754
942
|
|
|
755
943
|
# Re-infer parent states (like 'Busy') after updating child job states
|
|
756
944
|
DeviceInfo.set_all_states(app.partitions)
|
|
@@ -781,7 +969,16 @@ class MainScreen(DiskWipeScreen):
|
|
|
781
969
|
# Create context with partition reference
|
|
782
970
|
ctx = Context(genre='disk' if partition.parent is None else 'partition',
|
|
783
971
|
partition=partition)
|
|
784
|
-
|
|
972
|
+
# For disks, underline just the alphanumeric part of fw capability text
|
|
973
|
+
fw_ul = getattr(partition, '_fw_underline', None)
|
|
974
|
+
if fw_ul:
|
|
975
|
+
ul_start, ul_end = fw_ul
|
|
976
|
+
app.win.add_body(partition.line[:ul_start], attr=attr, context=ctx)
|
|
977
|
+
ul_attr = (attr or cs.A_NORMAL) | cs.A_UNDERLINE
|
|
978
|
+
app.win.add_body(partition.line[ul_start:ul_end], attr=ul_attr, resume=True)
|
|
979
|
+
app.win.add_body(partition.line[ul_end:], attr=attr, resume=True)
|
|
980
|
+
else:
|
|
981
|
+
app.win.add_body(partition.line, attr=attr, context=ctx)
|
|
785
982
|
if partition.parent is None and app.opts.port_serial != 'Off':
|
|
786
983
|
doit = bool(app.opts.port_serial == 'On')
|
|
787
984
|
if not doit:
|
|
@@ -790,16 +987,19 @@ class MainScreen(DiskWipeScreen):
|
|
|
790
987
|
doit = True
|
|
791
988
|
self.persist_port_serial.add(partition.name)
|
|
792
989
|
if doit:
|
|
793
|
-
|
|
794
|
-
|
|
990
|
+
# Check if this disk has any visible child partitions
|
|
991
|
+
has_children = partition.name in parent_last_child
|
|
992
|
+
line = self._port_serial_line(partition, has_children)
|
|
993
|
+
port_attr = (attr or cs.A_NORMAL) & ~cs.A_BOLD
|
|
994
|
+
app.win.add_body(line, attr=port_attr, context=Context(genre='DECOR'))
|
|
795
995
|
|
|
796
996
|
# Show inline confirmation prompt if this is the partition being confirmed
|
|
797
997
|
if app.confirmation.active and app.confirmation.identity == partition.name:
|
|
798
998
|
# Build confirmation message
|
|
799
999
|
if app.confirmation.action_type == 'wipe':
|
|
800
|
-
msg = f'⚠️
|
|
1000
|
+
msg = f'⚠️ WIPE'
|
|
801
1001
|
else: # verify
|
|
802
|
-
msg = f'⚠️
|
|
1002
|
+
msg = f'⚠️ VERIFY [writes marker]'
|
|
803
1003
|
|
|
804
1004
|
# Add mode-specific prompt (base message without input)
|
|
805
1005
|
if app.confirmation.mode == 'yes':
|
|
@@ -807,11 +1007,11 @@ class MainScreen(DiskWipeScreen):
|
|
|
807
1007
|
elif app.confirmation.mode == 'identity':
|
|
808
1008
|
msg += f" - Type '{partition.name}': "
|
|
809
1009
|
elif app.confirmation.mode == 'choices':
|
|
810
|
-
choices_str = ',
|
|
811
|
-
msg += f"
|
|
1010
|
+
choices_str = ','.join(app.confirmation.choices)
|
|
1011
|
+
msg += f" Choice ({choices_str}): "
|
|
812
1012
|
|
|
813
1013
|
# Position message at fixed column (reduced from 28 to 20)
|
|
814
|
-
msg = ' ' *
|
|
1014
|
+
msg = ' ' * 5 + msg
|
|
815
1015
|
|
|
816
1016
|
# Add confirmation message base as DECOR (non-pickable)
|
|
817
1017
|
app.win.add_body(msg, attr=cs.color_pair(Theme.DANGER) | cs.A_BOLD,
|
|
@@ -863,9 +1063,18 @@ class MainScreen(DiskWipeScreen):
|
|
|
863
1063
|
"""Handle quit action (q or x key pressed)"""
|
|
864
1064
|
app = self.app
|
|
865
1065
|
|
|
1066
|
+
# Check for firmware wipes - cannot quit while they're running
|
|
1067
|
+
if app._has_any_firmware_wipes():
|
|
1068
|
+
# Show alert - cannot quit during firmware wipe
|
|
1069
|
+
app.filter_bar._text = 'Cannot quit: firmware wipe running'
|
|
1070
|
+
return
|
|
1071
|
+
|
|
866
1072
|
def stop_if_idle(part):
|
|
867
1073
|
if part.state[-1] == '%':
|
|
868
1074
|
if part.job and not part.job.done:
|
|
1075
|
+
# Skip firmware wipes - they cannot be stopped
|
|
1076
|
+
if app._is_firmware_wipe(part):
|
|
1077
|
+
return 1 # Count as running but don't stop
|
|
869
1078
|
part.job.do_abort = True
|
|
870
1079
|
return 1 if part.job else 0
|
|
871
1080
|
|
|
@@ -897,9 +1106,23 @@ class MainScreen(DiskWipeScreen):
|
|
|
897
1106
|
if app.test_state(part, to='0%'):
|
|
898
1107
|
self.clear_hotswap_marker(part)
|
|
899
1108
|
# Build choices: Zero, Rand, and any firmware wipe types
|
|
1109
|
+
# Sort all by rank (worst to best) with '*' on recommended (last) one
|
|
1110
|
+
from .DrivePreChecker import DrivePreChecker
|
|
900
1111
|
choices = ['Zero', 'Rand']
|
|
1112
|
+
fw_modes = []
|
|
901
1113
|
if part.hw_caps:
|
|
902
|
-
|
|
1114
|
+
# Strip '*' from hw_caps modes (already has it from display string)
|
|
1115
|
+
fw_modes = [m.strip().rstrip('*') for m in part.hw_caps.split(',')]
|
|
1116
|
+
choices.extend(fw_modes)
|
|
1117
|
+
# Use HDD rankings (prefer software wipes) ONLY if:
|
|
1118
|
+
# 1. Device reports as rotational, AND
|
|
1119
|
+
# 2. Device has no crypto-capable firmware wipes
|
|
1120
|
+
# Note: 'Enhanced' is available on HDDs too (slow overwrite, not crypto)
|
|
1121
|
+
# Only SCrypto/Crypto/FCrypto definitively indicate SSD with crypto
|
|
1122
|
+
is_rotational = getattr(part, 'is_rotational', False)
|
|
1123
|
+
has_crypto_fw = any(m in fw_modes for m in ('SCrypto', 'Crypto', 'FCrypto'))
|
|
1124
|
+
use_hdd_ranking = is_rotational and not has_crypto_fw
|
|
1125
|
+
choices = DrivePreChecker.sort_modes_by_rank(choices, is_rotational=use_hdd_ranking)
|
|
903
1126
|
app.confirmation.start(action_type='wipe',
|
|
904
1127
|
identity=part.name, mode='choices', choices=choices)
|
|
905
1128
|
app.win.passthrough_mode = True
|
|
@@ -913,6 +1136,12 @@ class MainScreen(DiskWipeScreen):
|
|
|
913
1136
|
# Use get_actions() to ensure we use the same logic as the header display
|
|
914
1137
|
_, actions = app.get_actions(part)
|
|
915
1138
|
if 'v' in actions:
|
|
1139
|
+
# Safety check: Prevent verification on firmware wipes
|
|
1140
|
+
# (Firmware wipes have built-in pre/post verification)
|
|
1141
|
+
if self._is_firmware_wipe_marker(part):
|
|
1142
|
+
part.mounts = ['⚠ Firmware wipes have built-in verification - standard verify not allowed']
|
|
1143
|
+
return
|
|
1144
|
+
|
|
916
1145
|
self.clear_hotswap_marker(part)
|
|
917
1146
|
# Check if this is an unmarked disk/partition (potential data loss risk)
|
|
918
1147
|
# Whole disks (no parent) or partitions without filesystems need confirmation
|
|
@@ -934,38 +1163,119 @@ class MainScreen(DiskWipeScreen):
|
|
|
934
1163
|
def scan_all_devices_ACTION(self):
|
|
935
1164
|
""" Trigger a re-scan of all devices to make the appear
|
|
936
1165
|
quicker in the list"""
|
|
1166
|
+
# Show temporary feedback
|
|
1167
|
+
self.app.win.flash('Scanning devices and rechecking firmware capabilities...', duration=0.75)
|
|
1168
|
+
|
|
1169
|
+
# SCSI host rescan (for SATA devices)
|
|
937
1170
|
base_path = '/sys/class/scsi_host'
|
|
938
|
-
if
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
1171
|
+
if os.path.exists(base_path):
|
|
1172
|
+
for host in os.listdir(base_path):
|
|
1173
|
+
scan_file = os.path.join(base_path, host, 'scan')
|
|
1174
|
+
if os.path.exists(scan_file):
|
|
1175
|
+
try:
|
|
1176
|
+
with open(scan_file, 'w', encoding='utf-8') as f:
|
|
1177
|
+
f.write("- - -")
|
|
1178
|
+
except Exception:
|
|
1179
|
+
pass
|
|
1180
|
+
|
|
1181
|
+
# Rebind any unbound NVMe devices
|
|
1182
|
+
self._rebind_nvme_devices()
|
|
1183
|
+
|
|
1184
|
+
# Reset hw_caps stickiness for all devices so they'll be re-probed
|
|
1185
|
+
# This allows detecting hardware state changes after sleep/wake cycles
|
|
1186
|
+
if self.app.worker_manager:
|
|
1187
|
+
for partition in self.app.partitions.values():
|
|
1188
|
+
# Queue worker to re-probe this device's capabilities
|
|
1189
|
+
if partition.parent: # only need to do whole disks
|
|
1190
|
+
continue
|
|
1191
|
+
self.app.worker_manager.request_hw_caps(partition.name)
|
|
1192
|
+
# Clear cached values so UI refreshes with new probing state
|
|
1193
|
+
partition.hw_caps = ''
|
|
1194
|
+
partition.hw_nopes = ''
|
|
1195
|
+
partition.hw_caps_state = ProbeState.PENDING
|
|
948
1196
|
|
|
949
1197
|
def delete_device_ACTION(self):
|
|
950
|
-
""" DEL key -- Cause the OS to drop a
|
|
951
|
-
can be replaced sooner """
|
|
1198
|
+
""" DEL key -- Cause the OS to drop a SATA device or unbind an NVMe device
|
|
1199
|
+
so it can be replaced sooner """
|
|
952
1200
|
app = self.app
|
|
953
1201
|
ctx = app.win.get_picked_context()
|
|
954
1202
|
if ctx and hasattr(ctx, 'partition'):
|
|
955
1203
|
part = ctx.partition
|
|
956
1204
|
if not part or part.parent or not app.test_state(part, to='0%'):
|
|
957
1205
|
return
|
|
958
|
-
|
|
959
|
-
if
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1206
|
+
# NVMe unbind - write PCI address to driver unbind file
|
|
1207
|
+
if part.name.startswith('nvme'):
|
|
1208
|
+
pci_addr = self._get_nvme_pci_address(part.name)
|
|
1209
|
+
if pci_addr:
|
|
1210
|
+
unbind_path = "/sys/bus/pci/drivers/nvme/unbind"
|
|
1211
|
+
if os.path.exists(unbind_path):
|
|
1212
|
+
try:
|
|
1213
|
+
with open(unbind_path, 'w', encoding='utf-8') as f:
|
|
1214
|
+
f.write(pci_addr)
|
|
1215
|
+
return True
|
|
1216
|
+
except Exception:
|
|
1217
|
+
pass
|
|
1218
|
+
# SATA/IDE delete - write 1 to device delete file
|
|
1219
|
+
else:
|
|
1220
|
+
path = f"/sys/block/{part.name}/device/delete"
|
|
1221
|
+
if os.path.exists(path):
|
|
1222
|
+
try:
|
|
1223
|
+
with open(path, 'w', encoding='utf-8') as f:
|
|
1224
|
+
f.write("1")
|
|
1225
|
+
return True
|
|
1226
|
+
except Exception:
|
|
1227
|
+
pass
|
|
1228
|
+
|
|
1229
|
+
def _get_nvme_pci_address(self, device_name):
|
|
1230
|
+
"""Get the full PCI address for an NVMe device (e.g., '0000:01:00.0')
|
|
1231
|
+
|
|
1232
|
+
The sysfs path may contain multiple PCI addresses (bridges), so we need
|
|
1233
|
+
the last one before /nvme/ which is the actual NVMe controller.
|
|
1234
|
+
"""
|
|
1235
|
+
try:
|
|
1236
|
+
sysfs_path = f'/sys/class/block/{device_name}'
|
|
1237
|
+
if os.path.exists(sysfs_path):
|
|
1238
|
+
real_path = os.path.realpath(sysfs_path)
|
|
1239
|
+
# Find all PCI addresses and take the last one (the NVMe controller)
|
|
1240
|
+
pci_matches = re.findall(r'(0000:[0-9a-f]{2}:[0-9a-f]{2}\.[0-9a-f])', real_path, re.I)
|
|
1241
|
+
if pci_matches:
|
|
1242
|
+
return pci_matches[-1]
|
|
1243
|
+
except Exception:
|
|
1244
|
+
pass
|
|
1245
|
+
return None
|
|
1246
|
+
|
|
1247
|
+
def _rebind_nvme_devices(self):
|
|
1248
|
+
"""Find and rebind any unbound NVMe devices.
|
|
1249
|
+
|
|
1250
|
+
Scans PCI devices for NVMe controllers (class 0x010802) that have no
|
|
1251
|
+
driver bound, and attempts to bind them to the nvme driver.
|
|
1252
|
+
"""
|
|
1253
|
+
pci_devices = '/sys/bus/pci/devices'
|
|
1254
|
+
nvme_bind = '/sys/bus/pci/drivers/nvme/bind'
|
|
1255
|
+
if not os.path.exists(pci_devices) or not os.path.exists(nvme_bind):
|
|
1256
|
+
return
|
|
1257
|
+
|
|
1258
|
+
for pci_addr in os.listdir(pci_devices):
|
|
1259
|
+
device_path = os.path.join(pci_devices, pci_addr)
|
|
1260
|
+
# Check if this is an NVMe controller (class 0x010802)
|
|
1261
|
+
class_file = os.path.join(device_path, 'class')
|
|
1262
|
+
try:
|
|
1263
|
+
with open(class_file, 'r', encoding='utf-8') as f:
|
|
1264
|
+
device_class = f.read().strip()
|
|
1265
|
+
if device_class != '0x010802':
|
|
1266
|
+
continue
|
|
1267
|
+
# Check if driver is already bound
|
|
1268
|
+
driver_link = os.path.join(device_path, 'driver')
|
|
1269
|
+
if os.path.exists(driver_link):
|
|
1270
|
+
continue
|
|
1271
|
+
# Try to bind to nvme driver
|
|
1272
|
+
with open(nvme_bind, 'w', encoding='utf-8') as f:
|
|
1273
|
+
f.write(pci_addr)
|
|
1274
|
+
except Exception:
|
|
1275
|
+
pass
|
|
966
1276
|
|
|
967
1277
|
def stop_ACTION(self):
|
|
968
|
-
"""Handle 's' key"""
|
|
1278
|
+
"""Handle 's' key - stop current wipe (but not firmware wipes)"""
|
|
969
1279
|
app = self.app
|
|
970
1280
|
if app.pick_is_running:
|
|
971
1281
|
ctx = app.win.get_picked_context()
|
|
@@ -973,15 +1283,21 @@ class MainScreen(DiskWipeScreen):
|
|
|
973
1283
|
part = ctx.partition
|
|
974
1284
|
if part.state[-1] == '%':
|
|
975
1285
|
if part.job and not part.job.done:
|
|
1286
|
+
# Skip firmware wipes - they cannot be safely stopped
|
|
1287
|
+
if app._is_firmware_wipe(part):
|
|
1288
|
+
return
|
|
976
1289
|
part.job.do_abort = True
|
|
977
1290
|
|
|
978
1291
|
|
|
979
1292
|
def stop_all_ACTION(self):
|
|
980
|
-
"""Handle 'S' key"""
|
|
1293
|
+
"""Handle 'S' key - stop all wipes (but not firmware wipes)"""
|
|
981
1294
|
app = self.app
|
|
982
1295
|
for part in app.partitions.values():
|
|
983
1296
|
if part.state[-1] == '%':
|
|
984
1297
|
if part.job and not part.job.done:
|
|
1298
|
+
# Skip firmware wipes - they cannot be safely stopped
|
|
1299
|
+
if app._is_firmware_wipe(part):
|
|
1300
|
+
continue
|
|
985
1301
|
part.job.do_abort = True
|
|
986
1302
|
|
|
987
1303
|
def block_ACTION(self):
|
|
@@ -1025,6 +1341,20 @@ class HelpScreen(DiskWipeScreen):
|
|
|
1025
1341
|
spinner.show_help_nav_keys(app.win)
|
|
1026
1342
|
spinner.show_help_body(app.win)
|
|
1027
1343
|
|
|
1344
|
+
# Add CLI Options section
|
|
1345
|
+
app.win.add_body('Command Line Arguments:', attr=cs.A_UNDERLINE)
|
|
1346
|
+
opts, wid = app.opts, 8
|
|
1347
|
+
cli_options = [
|
|
1348
|
+
f'--mode: . . . . {opts.mode:<{wid}} Wipe mode',
|
|
1349
|
+
f'--passes: . . . {opts.passes:<{wid}} Passes for software wipes',
|
|
1350
|
+
f'--verify-pct: . {opts.verify_pct:<{wid}} Verification %',
|
|
1351
|
+
f'--port-serial: {opts.port_serial:<{wid}} Show port/serial/FwCAPS',
|
|
1352
|
+
f'--slowdown-stop: {opts.slowdown_stop:<{wid}} Stop if disk slows',
|
|
1353
|
+
f'--stall-timeout: {opts.stall_timeout:<{wid}} Stall timeout in sec',
|
|
1354
|
+
]
|
|
1355
|
+
for opt in cli_options:
|
|
1356
|
+
app.win.add_body(opt, attr=cs.A_DIM)
|
|
1357
|
+
|
|
1028
1358
|
|
|
1029
1359
|
|
|
1030
1360
|
class HistoryScreen(DiskWipeScreen):
|
|
@@ -1087,6 +1417,15 @@ class HistoryScreen(DiskWipeScreen):
|
|
|
1087
1417
|
|
|
1088
1418
|
def draw_screen(self):
|
|
1089
1419
|
"""Draw the history screen with structured log entries"""
|
|
1420
|
+
|
|
1421
|
+
def format_ago(timestamp):
|
|
1422
|
+
nonlocal now_dt
|
|
1423
|
+
ts = datetime.fromisoformat(timestamp)
|
|
1424
|
+
delta = now_dt - ts
|
|
1425
|
+
return Utils.ago_str(int(round(delta.total_seconds())))
|
|
1426
|
+
|
|
1427
|
+
|
|
1428
|
+
now_dt = datetime.now()
|
|
1090
1429
|
app = self.app
|
|
1091
1430
|
win = app.win
|
|
1092
1431
|
win.set_pick_mode(True)
|
|
@@ -1126,7 +1465,7 @@ class HistoryScreen(DiskWipeScreen):
|
|
|
1126
1465
|
|
|
1127
1466
|
# Header
|
|
1128
1467
|
# header_line = f'ESC:back [e]xpand [/]search {len(self.filtered_entries)}/{len(self.entries)} ({level_summary}) '
|
|
1129
|
-
header_line = f'ESC:back [e]xpand [/]search {len(self.filtered_entries)}/{len(self.entries)} '
|
|
1468
|
+
header_line = f'ESC:back [e]xpand [c]opy [/]search {len(self.filtered_entries)}/{len(self.entries)} '
|
|
1130
1469
|
if search_display:
|
|
1131
1470
|
header_line += f'/ {search_display}'
|
|
1132
1471
|
else:
|
|
@@ -1141,8 +1480,17 @@ class HistoryScreen(DiskWipeScreen):
|
|
|
1141
1480
|
# Get display summary from entry
|
|
1142
1481
|
summary = entry.display_summary
|
|
1143
1482
|
|
|
1144
|
-
# Format timestamp
|
|
1145
|
-
|
|
1483
|
+
# Format timestamp based on spinner setting
|
|
1484
|
+
time_format = app.opts.hist_time_format
|
|
1485
|
+
|
|
1486
|
+
if time_format == 'ago':
|
|
1487
|
+
timestamp_display = f"{format_ago(timestamp):>6}"
|
|
1488
|
+
elif time_format == 'ago+time':
|
|
1489
|
+
ago = format_ago(timestamp)
|
|
1490
|
+
time_str = timestamp[:19]
|
|
1491
|
+
timestamp_display = f"{ago:>6} {time_str}"
|
|
1492
|
+
else: # 'time'
|
|
1493
|
+
timestamp_display = timestamp[:19] # Just the date and time part (YYYY-MM-DD HH:MM:SS)
|
|
1146
1494
|
|
|
1147
1495
|
level = entry.level
|
|
1148
1496
|
|
|
@@ -1193,13 +1541,89 @@ class HistoryScreen(DiskWipeScreen):
|
|
|
1193
1541
|
timestamp = ctx.timestamp
|
|
1194
1542
|
# Toggle between collapsed and expanded
|
|
1195
1543
|
current = self.expands.get(timestamp, False)
|
|
1196
|
-
if current:
|
|
1197
|
-
del self.expands[timestamp]
|
|
1544
|
+
if current: # Collapsing
|
|
1545
|
+
del self.expands[timestamp]
|
|
1546
|
+
# Search backwards to find first context with matching timestamp
|
|
1547
|
+
# This should be the header line of this entry
|
|
1548
|
+
test_pos = win.pick_pos - 1
|
|
1549
|
+
while test_pos >= 0:
|
|
1550
|
+
test_ctx = win.body.contexts[test_pos]
|
|
1551
|
+
if test_ctx and hasattr(test_ctx, 'timestamp') and test_ctx.timestamp == timestamp:
|
|
1552
|
+
win.pick_pos = test_pos
|
|
1553
|
+
test_pos -= 1
|
|
1554
|
+
else:
|
|
1555
|
+
return
|
|
1198
1556
|
else:
|
|
1199
|
-
|
|
1557
|
+
# Expanding - just toggle, cursor stays where it is
|
|
1558
|
+
self.expands[timestamp] = True
|
|
1200
1559
|
|
|
1201
1560
|
def filter_ACTION(self):
|
|
1202
1561
|
"""'/' key - Start incremental search"""
|
|
1203
1562
|
app = self.app
|
|
1204
1563
|
self.search_bar.start(self.prev_filter)
|
|
1205
1564
|
app.win.passthrough_mode = True
|
|
1565
|
+
|
|
1566
|
+
def copy_ACTION(self):
|
|
1567
|
+
"""'c' key - Copy current entry to clipboard or print to terminal"""
|
|
1568
|
+
from .Utils import ClipboardHelper
|
|
1569
|
+
app = self.app
|
|
1570
|
+
win = app.win
|
|
1571
|
+
ctx = win.get_picked_context()
|
|
1572
|
+
|
|
1573
|
+
if not ctx or not hasattr(ctx, 'timestamp'):
|
|
1574
|
+
return
|
|
1575
|
+
|
|
1576
|
+
# Find the entry by timestamp
|
|
1577
|
+
timestamp = ctx.timestamp
|
|
1578
|
+
entry = None
|
|
1579
|
+
for e in self.entries:
|
|
1580
|
+
if e.timestamp == timestamp:
|
|
1581
|
+
entry = e
|
|
1582
|
+
break
|
|
1583
|
+
|
|
1584
|
+
if not entry:
|
|
1585
|
+
return
|
|
1586
|
+
|
|
1587
|
+
# Format entry as JSON
|
|
1588
|
+
entry_text = json.dumps(entry.to_dict(), indent=2)
|
|
1589
|
+
|
|
1590
|
+
# Try clipboard first
|
|
1591
|
+
if ClipboardHelper.has_clipboard():
|
|
1592
|
+
success, error = ClipboardHelper.copy(entry_text)
|
|
1593
|
+
if success:
|
|
1594
|
+
# Show brief success indicator - use the header context temporarily
|
|
1595
|
+
# The message will be visible until next refresh
|
|
1596
|
+
win.add_header(f'Copied to clipboard ({ClipboardHelper.get_method_name()})')
|
|
1597
|
+
else:
|
|
1598
|
+
win.add_header(f'Clipboard error: {error}')
|
|
1599
|
+
else:
|
|
1600
|
+
# Terminal fallback: exit curses, print, wait for input
|
|
1601
|
+
self._copy_terminal_fallback(entry_text)
|
|
1602
|
+
|
|
1603
|
+
def _copy_terminal_fallback(self, text):
|
|
1604
|
+
"""Print entry to terminal when clipboard unavailable (e.g., SSH sessions)."""
|
|
1605
|
+
from .Utils import ClipboardHelper
|
|
1606
|
+
app = self.app
|
|
1607
|
+
|
|
1608
|
+
# Exit curses to use the terminal
|
|
1609
|
+
ConsoleWindow.stop_curses()
|
|
1610
|
+
os.system('clear; stty sane')
|
|
1611
|
+
|
|
1612
|
+
# Print with border
|
|
1613
|
+
print('=' * 60)
|
|
1614
|
+
print('LOG ENTRY (copy manually from terminal):')
|
|
1615
|
+
print('=' * 60)
|
|
1616
|
+
print(text)
|
|
1617
|
+
print('=' * 60)
|
|
1618
|
+
print(f'\nClipboard: {ClipboardHelper.get_method_name()}')
|
|
1619
|
+
print('\nPress ENTER to return to dwipe...')
|
|
1620
|
+
|
|
1621
|
+
# Wait for user input
|
|
1622
|
+
try:
|
|
1623
|
+
input()
|
|
1624
|
+
except EOFError:
|
|
1625
|
+
pass
|
|
1626
|
+
|
|
1627
|
+
# Restore curses
|
|
1628
|
+
ConsoleWindow.start_curses()
|
|
1629
|
+
app.win.pick_pos = app.win.pick_pos # Force position refresh
|