dwipe 2.0.2__py3-none-any.whl → 3.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dwipe/DeviceChangeMonitor.py +244 -0
- dwipe/DeviceInfo.py +592 -193
- dwipe/DeviceWorker.py +572 -0
- dwipe/DiskWipe.py +569 -136
- 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.1.dist-info}/METADATA +218 -99
- dwipe-3.0.1.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.1.dist-info}/WHEEL +0 -0
- {dwipe-2.0.2.dist-info → dwipe-3.0.1.dist-info}/entry_points.txt +0 -0
- {dwipe-2.0.2.dist-info → dwipe-3.0.1.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,47 @@ 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
|
+
# Re-probe after wipe: worker state is still READY, need force
|
|
450
|
+
if not (ns.hw_caps or ns.hw_nopes):
|
|
451
|
+
ns.hw_caps_state = ProbeState.PENDING
|
|
452
|
+
self.worker_manager.request_hw_caps(ns.name, force=True)
|
|
453
|
+
self.dev_info.get_hw_capabilities(ns)
|
|
454
|
+
|
|
455
|
+
def _poll_hw_caps_updates(self):
|
|
456
|
+
"""Poll for hw_caps probe completion without full device refresh.
|
|
457
|
+
|
|
458
|
+
Called every 0.25 seconds in main loop to quickly show hw_caps
|
|
459
|
+
results as soon as worker threads complete probes.
|
|
460
|
+
"""
|
|
461
|
+
if not self.dev_info:
|
|
462
|
+
return
|
|
463
|
+
from .DeviceWorker import ProbeState
|
|
464
|
+
# Only update devices that are still probing
|
|
465
|
+
for ns in self.partitions.values():
|
|
466
|
+
if ns.parent:
|
|
467
|
+
continue
|
|
468
|
+
# Update any device that's still PENDING or PROBING
|
|
469
|
+
if ns.hw_caps_state in (ProbeState.PENDING, ProbeState.PROBING):
|
|
357
470
|
self.dev_info.get_hw_capabilities(ns)
|
|
358
471
|
|
|
359
|
-
|
|
360
472
|
def main_loop(self):
|
|
361
473
|
"""Main event loop"""
|
|
362
474
|
|
|
@@ -379,22 +491,25 @@ class DiskWipe:
|
|
|
379
491
|
pick_attr=cs.A_REVERSE, # Use reverse video for pick highlighting
|
|
380
492
|
ctrl_c_terminates=False,
|
|
381
493
|
)
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
print("
|
|
494
|
+
device_monitor = DeviceChangeMonitor(check_interval=1.0)
|
|
495
|
+
device_monitor.start()
|
|
496
|
+
print("Discovering devices...")
|
|
497
|
+
# Create persistent worker manager for hw_caps probing
|
|
498
|
+
# This is reused across device refreshes to allow probes to complete
|
|
499
|
+
from .DeviceWorker import DeviceWorkerManager
|
|
500
|
+
from .DrivePreChecker import DrivePreChecker
|
|
501
|
+
worker_manager = DeviceWorkerManager(DrivePreChecker())
|
|
385
502
|
# 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
|
-
|
|
396
|
-
exit(1)
|
|
397
|
-
time.sleep(0.2)
|
|
503
|
+
info = DeviceInfo(opts=self.opts, persistent_state=self.persistent_state,
|
|
504
|
+
worker_manager=worker_manager)
|
|
505
|
+
self.partitions = info.assemble_partitions(self.partitions)
|
|
506
|
+
# Start probing hw_caps immediately instead of waiting for first 3s refresh
|
|
507
|
+
self.dev_info = info
|
|
508
|
+
self.worker_manager = worker_manager
|
|
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,21 @@ 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.get_hw_caps_when_needed()
|
|
442
|
-
self.dev_info = info
|
|
443
554
|
pick_range = info.get_pick_range()
|
|
444
555
|
self.win.set_pick_range(pick_range[0], pick_range[1])
|
|
445
556
|
|
|
446
557
|
|
|
447
558
|
check_devices_mono = time.monotonic()
|
|
559
|
+
cached_worker_state = {} # Track marker/hw_caps state to detect updates
|
|
448
560
|
|
|
449
561
|
try:
|
|
450
562
|
while True:
|
|
@@ -459,20 +571,55 @@ class DiskWipe:
|
|
|
459
571
|
# Handle actions using perform_actions
|
|
460
572
|
self.stack.perform_actions(spin)
|
|
461
573
|
|
|
462
|
-
#
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
574
|
+
# Poll for hw_caps completion without full device refresh (every 0.25s)
|
|
575
|
+
# This lets us show results quickly even though commands take 1-3 seconds
|
|
576
|
+
self._poll_hw_caps_updates()
|
|
577
|
+
|
|
578
|
+
# Detect suspend/resume: loop runs every ~0.25s, so a large
|
|
579
|
+
# gap in monotonic time means the system was likely suspended
|
|
580
|
+
now_mono = time.monotonic()
|
|
581
|
+
loop_gap = now_mono - check_devices_mono
|
|
582
|
+
if loop_gap > 5.0 and hasattr(self, '_last_loop_mono'):
|
|
583
|
+
suspend_gap = now_mono - self._last_loop_mono
|
|
584
|
+
if suspend_gap > 5.0:
|
|
585
|
+
self.win.flash(f'Suspend detected ({suspend_gap:.0f}s gap), rescanning...', duration=1.5)
|
|
586
|
+
self.screens[MAIN_ST].scan_all_devices_ACTION()
|
|
587
|
+
self._last_loop_mono = now_mono
|
|
588
|
+
|
|
589
|
+
# Check for device changes from background monitor
|
|
590
|
+
devices_changed = device_monitor.get_and_clear()
|
|
591
|
+
time_since_refresh = now_mono - check_devices_mono
|
|
592
|
+
|
|
593
|
+
# Build current worker state for comparison
|
|
594
|
+
current_worker_state = {}
|
|
595
|
+
if self.worker_manager:
|
|
596
|
+
for device_name, part in self.partitions.items():
|
|
597
|
+
current_worker_state[device_name] = {
|
|
598
|
+
'marker': part.marker,
|
|
599
|
+
'hw_caps_state': part.hw_caps_state
|
|
600
|
+
}
|
|
601
|
+
# Check if worker has any updates (markers or hw_caps changes)
|
|
602
|
+
worker_has_updates = (
|
|
603
|
+
self.worker_manager.has_updates(cached_worker_state)
|
|
604
|
+
)
|
|
605
|
+
else:
|
|
606
|
+
worker_has_updates = False
|
|
607
|
+
|
|
608
|
+
if (devices_changed or worker_has_updates or
|
|
609
|
+
time_since_refresh > 3.0):
|
|
610
|
+
# Refresh if: device changes, worker updates, OR periodic (3s default)
|
|
611
|
+
info = DeviceInfo(opts=self.opts, persistent_state=self.persistent_state,
|
|
612
|
+
worker_manager=self.worker_manager)
|
|
613
|
+
self.partitions = info.assemble_partitions(self.partitions)
|
|
471
614
|
self.dev_info = info
|
|
472
615
|
# Update pick range to highlight NAME through SIZE fields
|
|
473
616
|
pick_range = info.get_pick_range()
|
|
474
617
|
self.win.set_pick_range(pick_range[0], pick_range[1])
|
|
618
|
+
# Probe hw_caps for devices that need it (only once per refresh, not every draw)
|
|
619
|
+
self.get_hw_caps_when_needed()
|
|
475
620
|
check_devices_mono = time.monotonic()
|
|
621
|
+
# Update cached state after refresh
|
|
622
|
+
cached_worker_state = current_worker_state.copy()
|
|
476
623
|
|
|
477
624
|
# Save any persistent state changes
|
|
478
625
|
self.persistent_state.save_updated_opts(self.opts)
|
|
@@ -481,7 +628,10 @@ class DiskWipe:
|
|
|
481
628
|
self.win.clear()
|
|
482
629
|
finally:
|
|
483
630
|
# Clean up monitor thread on exit
|
|
484
|
-
|
|
631
|
+
device_monitor.stop()
|
|
632
|
+
# Clean up persistent worker manager
|
|
633
|
+
if hasattr(self, 'worker_manager') and self.worker_manager:
|
|
634
|
+
self.worker_manager.stop_all()
|
|
485
635
|
|
|
486
636
|
class DiskWipeScreen(Screen):
|
|
487
637
|
""" TBD """
|
|
@@ -505,35 +655,30 @@ class MainScreen(DiskWipeScreen):
|
|
|
505
655
|
self.persist_port_serial = set()
|
|
506
656
|
|
|
507
657
|
|
|
508
|
-
def _port_serial_line(self, partition):
|
|
658
|
+
def _port_serial_line(self, partition, has_children=True):
|
|
509
659
|
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}'
|
|
660
|
+
sep = ' '
|
|
661
|
+
# Sanitize port/serial - some USB bridges return strings with embedded nulls
|
|
662
|
+
port = partition.port.replace('\x00', '') if partition.port else ''
|
|
663
|
+
serial = partition.serial.replace('\x00', '') if partition.serial else ''
|
|
518
664
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
app = self.app
|
|
665
|
+
# Get column widths (with defaults if wids not yet initialized)
|
|
666
|
+
wid_state = wids.state if wids else 5
|
|
522
667
|
|
|
523
|
-
|
|
524
|
-
|
|
668
|
+
# Use corner └ if no children below, or vertical │ if there are children to connect to
|
|
669
|
+
connector = '│' if has_children else ' '
|
|
525
670
|
|
|
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)
|
|
671
|
+
# Build base line: state padding + connector + port/serial
|
|
672
|
+
base = f'{"":>{wid_state}}{sep}{connector} └────── {port:<12} {serial}'
|
|
533
673
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
674
|
+
return base
|
|
675
|
+
|
|
676
|
+
def do_job_maintenance(self):
|
|
677
|
+
""" Check all the jobs in progress and advance their state
|
|
678
|
+
appropriately.
|
|
679
|
+
"""
|
|
680
|
+
app = self.app
|
|
681
|
+
for _, partition in app.partitions.items():
|
|
537
682
|
partition.line = None
|
|
538
683
|
if partition.job:
|
|
539
684
|
if partition.job.done:
|
|
@@ -629,6 +774,7 @@ class MainScreen(DiskWipeScreen):
|
|
|
629
774
|
partition.job = None
|
|
630
775
|
partition.marker_checked = False # Reset to "dont-know" - will re-read on next scan
|
|
631
776
|
partition.marker = '' # Clear stale marker string to avoid showing old data during re-read
|
|
777
|
+
partition.monitor_marker = False # Stop monitoring verify job
|
|
632
778
|
else:
|
|
633
779
|
# Wipe job completed (with or without auto-verify)
|
|
634
780
|
# Check if stopped during verify phase (after successful write)
|
|
@@ -716,8 +862,9 @@ class MainScreen(DiskWipeScreen):
|
|
|
716
862
|
partition.job = None
|
|
717
863
|
partition.marker_checked = False # Reset to "dont-know" - will re-read on next scan
|
|
718
864
|
partition.marker = '' # Clear stale marker string to avoid showing old data during re-read
|
|
865
|
+
partition.monitor_marker = True # Start monitoring the new marker
|
|
719
866
|
if partition.job:
|
|
720
|
-
elapsed, pct, rate, until = partition.job.get_status()
|
|
867
|
+
elapsed, pct, rate, until, more_state = partition.job.get_status()
|
|
721
868
|
|
|
722
869
|
# Get task display name (Zero, Rand, Crypto, Verify, etc.)
|
|
723
870
|
task_name = ""
|
|
@@ -731,26 +878,76 @@ class MainScreen(DiskWipeScreen):
|
|
|
731
878
|
partition.mounts = [f'{task_name} {pct} {elapsed} -{until} {rate}']
|
|
732
879
|
else:
|
|
733
880
|
partition.mounts = [f'{task_name} {pct} {elapsed}']
|
|
881
|
+
if more_state:
|
|
882
|
+
partition.mounts[0] += f' {more_state}'
|
|
734
883
|
else:
|
|
735
884
|
partition.state = pct
|
|
736
885
|
# Build progress line with task name
|
|
737
886
|
progress_parts = [task_name, elapsed, f'-{until}', rate]
|
|
738
887
|
|
|
739
|
-
# Only show slowdown/stall
|
|
740
|
-
|
|
741
|
-
|
|
888
|
+
# Only show slowdown/stall for WriteTask (not VerifyTask or FirmwareWipeTask)
|
|
889
|
+
from .FirmwareWipeTask import FirmwareWipeTask
|
|
890
|
+
from .WriteTask import WriteTask
|
|
891
|
+
current_task = partition.job.current_task
|
|
892
|
+
if current_task and isinstance(current_task, WriteTask) and not isinstance(current_task, FirmwareWipeTask):
|
|
742
893
|
slowdown = partition.job.max_slowdown_ratio
|
|
743
894
|
stall = partition.job.max_stall_secs
|
|
744
895
|
progress_parts.extend([f'÷{slowdown}', f'𝚫{Utils.ago_str(stall)}'])
|
|
745
896
|
|
|
897
|
+
if more_state:
|
|
898
|
+
progress_parts.append(more_state)
|
|
899
|
+
|
|
746
900
|
partition.mounts = [' '.join(progress_parts)]
|
|
747
901
|
|
|
748
902
|
if partition.parent and partition.parent in app.partitions and (
|
|
749
|
-
app.partitions[partition.parent].state
|
|
903
|
+
app.partitions[partition.parent].state in ('Blk', 'iBlk')):
|
|
750
904
|
continue
|
|
751
905
|
|
|
752
|
-
|
|
753
|
-
|
|
906
|
+
|
|
907
|
+
def draw_screen(self):
|
|
908
|
+
"""Draw the main device list"""
|
|
909
|
+
app = self.app
|
|
910
|
+
|
|
911
|
+
def wanted(name):
|
|
912
|
+
return not app.filter or app.filter.search(name)
|
|
913
|
+
|
|
914
|
+
self.do_job_maintenance()
|
|
915
|
+
|
|
916
|
+
app.win.set_pick_mode(True)
|
|
917
|
+
if app.opts.port_serial != 'Auto':
|
|
918
|
+
self.persist_port_serial = set() # name of disks
|
|
919
|
+
else: # if the disk goes away, clear persistence
|
|
920
|
+
for name in list(self.persist_port_serial):
|
|
921
|
+
if name not in app.partitions:
|
|
922
|
+
self.persist_port_serial.discard(name)
|
|
923
|
+
|
|
924
|
+
# process jobs and collect visible partitions, sorted by disk then partition
|
|
925
|
+
visible_partitions = []
|
|
926
|
+
# Get disks sorted alphabetically
|
|
927
|
+
disks = sorted([p for p in app.partitions.values() if p.parent is None],
|
|
928
|
+
key=lambda p: p.name)
|
|
929
|
+
for disk in disks:
|
|
930
|
+
if wanted(disk.name) or disk.job:
|
|
931
|
+
visible_partitions.append(disk)
|
|
932
|
+
# Add partitions for this disk, sorted alphabetically
|
|
933
|
+
parts = sorted([p for p in app.partitions.values() if p.parent == disk.name],
|
|
934
|
+
key=lambda p: p.name)
|
|
935
|
+
# If disk is directly blocked, hide children and aggregate their mounts
|
|
936
|
+
if disk.state == 'Blk':
|
|
937
|
+
# Collect all mounts from children, sort with "/" first (by name/length)
|
|
938
|
+
all_mounts = []
|
|
939
|
+
for part in parts:
|
|
940
|
+
all_mounts.extend(part.mounts)
|
|
941
|
+
# Sort: "/" first, then by name (implicitly by length since "/" is shortest)
|
|
942
|
+
all_mounts.sort(key=lambda m: (m != '/', m))
|
|
943
|
+
disk.aggregated_mounts = all_mounts
|
|
944
|
+
# Skip adding children to visible list
|
|
945
|
+
continue
|
|
946
|
+
else:
|
|
947
|
+
disk.aggregated_mounts = None
|
|
948
|
+
for part in parts:
|
|
949
|
+
if wanted(part.name) or part.job:
|
|
950
|
+
visible_partitions.append(part)
|
|
754
951
|
|
|
755
952
|
# Re-infer parent states (like 'Busy') after updating child job states
|
|
756
953
|
DeviceInfo.set_all_states(app.partitions)
|
|
@@ -781,7 +978,16 @@ class MainScreen(DiskWipeScreen):
|
|
|
781
978
|
# Create context with partition reference
|
|
782
979
|
ctx = Context(genre='disk' if partition.parent is None else 'partition',
|
|
783
980
|
partition=partition)
|
|
784
|
-
|
|
981
|
+
# For disks, underline just the alphanumeric part of fw capability text
|
|
982
|
+
fw_ul = getattr(partition, '_fw_underline', None)
|
|
983
|
+
if fw_ul:
|
|
984
|
+
ul_start, ul_end = fw_ul
|
|
985
|
+
app.win.add_body(partition.line[:ul_start], attr=attr, context=ctx)
|
|
986
|
+
ul_attr = (attr or cs.A_NORMAL) | cs.A_UNDERLINE
|
|
987
|
+
app.win.add_body(partition.line[ul_start:ul_end], attr=ul_attr, resume=True)
|
|
988
|
+
app.win.add_body(partition.line[ul_end:], attr=attr, resume=True)
|
|
989
|
+
else:
|
|
990
|
+
app.win.add_body(partition.line, attr=attr, context=ctx)
|
|
785
991
|
if partition.parent is None and app.opts.port_serial != 'Off':
|
|
786
992
|
doit = bool(app.opts.port_serial == 'On')
|
|
787
993
|
if not doit:
|
|
@@ -790,16 +996,19 @@ class MainScreen(DiskWipeScreen):
|
|
|
790
996
|
doit = True
|
|
791
997
|
self.persist_port_serial.add(partition.name)
|
|
792
998
|
if doit:
|
|
793
|
-
|
|
794
|
-
|
|
999
|
+
# Check if this disk has any visible child partitions
|
|
1000
|
+
has_children = partition.name in parent_last_child
|
|
1001
|
+
line = self._port_serial_line(partition, has_children)
|
|
1002
|
+
port_attr = (attr or cs.A_NORMAL) & ~cs.A_BOLD
|
|
1003
|
+
app.win.add_body(line, attr=port_attr, context=Context(genre='DECOR'))
|
|
795
1004
|
|
|
796
1005
|
# Show inline confirmation prompt if this is the partition being confirmed
|
|
797
1006
|
if app.confirmation.active and app.confirmation.identity == partition.name:
|
|
798
1007
|
# Build confirmation message
|
|
799
1008
|
if app.confirmation.action_type == 'wipe':
|
|
800
|
-
msg = f'⚠️
|
|
1009
|
+
msg = f'⚠️ WIPE'
|
|
801
1010
|
else: # verify
|
|
802
|
-
msg = f'⚠️
|
|
1011
|
+
msg = f'⚠️ VERIFY [writes marker]'
|
|
803
1012
|
|
|
804
1013
|
# Add mode-specific prompt (base message without input)
|
|
805
1014
|
if app.confirmation.mode == 'yes':
|
|
@@ -807,11 +1016,11 @@ class MainScreen(DiskWipeScreen):
|
|
|
807
1016
|
elif app.confirmation.mode == 'identity':
|
|
808
1017
|
msg += f" - Type '{partition.name}': "
|
|
809
1018
|
elif app.confirmation.mode == 'choices':
|
|
810
|
-
choices_str = ',
|
|
811
|
-
msg += f"
|
|
1019
|
+
choices_str = ','.join(app.confirmation.choices)
|
|
1020
|
+
msg += f" Choice ({choices_str}): "
|
|
812
1021
|
|
|
813
1022
|
# Position message at fixed column (reduced from 28 to 20)
|
|
814
|
-
msg = ' ' *
|
|
1023
|
+
msg = ' ' * 5 + msg
|
|
815
1024
|
|
|
816
1025
|
# Add confirmation message base as DECOR (non-pickable)
|
|
817
1026
|
app.win.add_body(msg, attr=cs.color_pair(Theme.DANGER) | cs.A_BOLD,
|
|
@@ -863,9 +1072,18 @@ class MainScreen(DiskWipeScreen):
|
|
|
863
1072
|
"""Handle quit action (q or x key pressed)"""
|
|
864
1073
|
app = self.app
|
|
865
1074
|
|
|
1075
|
+
# Check for firmware wipes - cannot quit while they're running
|
|
1076
|
+
if app._has_any_firmware_wipes():
|
|
1077
|
+
# Show alert - cannot quit during firmware wipe
|
|
1078
|
+
app.filter_bar._text = 'Cannot quit: firmware wipe running'
|
|
1079
|
+
return
|
|
1080
|
+
|
|
866
1081
|
def stop_if_idle(part):
|
|
867
1082
|
if part.state[-1] == '%':
|
|
868
1083
|
if part.job and not part.job.done:
|
|
1084
|
+
# Skip firmware wipes - they cannot be stopped
|
|
1085
|
+
if app._is_firmware_wipe(part):
|
|
1086
|
+
return 1 # Count as running but don't stop
|
|
869
1087
|
part.job.do_abort = True
|
|
870
1088
|
return 1 if part.job else 0
|
|
871
1089
|
|
|
@@ -897,9 +1115,23 @@ class MainScreen(DiskWipeScreen):
|
|
|
897
1115
|
if app.test_state(part, to='0%'):
|
|
898
1116
|
self.clear_hotswap_marker(part)
|
|
899
1117
|
# Build choices: Zero, Rand, and any firmware wipe types
|
|
1118
|
+
# Sort all by rank (worst to best) with '*' on recommended (last) one
|
|
1119
|
+
from .DrivePreChecker import DrivePreChecker
|
|
900
1120
|
choices = ['Zero', 'Rand']
|
|
1121
|
+
fw_modes = []
|
|
901
1122
|
if part.hw_caps:
|
|
902
|
-
|
|
1123
|
+
# Strip '*' from hw_caps modes (already has it from display string)
|
|
1124
|
+
fw_modes = [m.strip().rstrip('*') for m in part.hw_caps.split(',')]
|
|
1125
|
+
choices.extend(fw_modes)
|
|
1126
|
+
# Use HDD rankings (prefer software wipes) ONLY if:
|
|
1127
|
+
# 1. Device reports as rotational, AND
|
|
1128
|
+
# 2. Device has no crypto-capable firmware wipes
|
|
1129
|
+
# Note: 'Enhanced' is available on HDDs too (slow overwrite, not crypto)
|
|
1130
|
+
# Only SCrypto/Crypto/FCrypto definitively indicate SSD with crypto
|
|
1131
|
+
is_rotational = getattr(part, 'is_rotational', False)
|
|
1132
|
+
has_crypto_fw = any(m in fw_modes for m in ('SCrypto', 'Crypto', 'FCrypto'))
|
|
1133
|
+
use_hdd_ranking = is_rotational and not has_crypto_fw
|
|
1134
|
+
choices = DrivePreChecker.sort_modes_by_rank(choices, is_rotational=use_hdd_ranking)
|
|
903
1135
|
app.confirmation.start(action_type='wipe',
|
|
904
1136
|
identity=part.name, mode='choices', choices=choices)
|
|
905
1137
|
app.win.passthrough_mode = True
|
|
@@ -913,6 +1145,12 @@ class MainScreen(DiskWipeScreen):
|
|
|
913
1145
|
# Use get_actions() to ensure we use the same logic as the header display
|
|
914
1146
|
_, actions = app.get_actions(part)
|
|
915
1147
|
if 'v' in actions:
|
|
1148
|
+
# Safety check: Prevent verification on firmware wipes
|
|
1149
|
+
# (Firmware wipes have built-in pre/post verification)
|
|
1150
|
+
if self._is_firmware_wipe_marker(part):
|
|
1151
|
+
part.mounts = ['⚠ Firmware wipes have built-in verification - standard verify not allowed']
|
|
1152
|
+
return
|
|
1153
|
+
|
|
916
1154
|
self.clear_hotswap_marker(part)
|
|
917
1155
|
# Check if this is an unmarked disk/partition (potential data loss risk)
|
|
918
1156
|
# Whole disks (no parent) or partitions without filesystems need confirmation
|
|
@@ -934,38 +1172,119 @@ class MainScreen(DiskWipeScreen):
|
|
|
934
1172
|
def scan_all_devices_ACTION(self):
|
|
935
1173
|
""" Trigger a re-scan of all devices to make the appear
|
|
936
1174
|
quicker in the list"""
|
|
1175
|
+
# Show temporary feedback
|
|
1176
|
+
self.app.win.flash('Scanning devices and rechecking firmware capabilities...', duration=0.75)
|
|
1177
|
+
|
|
1178
|
+
# SCSI host rescan (for SATA devices)
|
|
937
1179
|
base_path = '/sys/class/scsi_host'
|
|
938
|
-
if
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
1180
|
+
if os.path.exists(base_path):
|
|
1181
|
+
for host in os.listdir(base_path):
|
|
1182
|
+
scan_file = os.path.join(base_path, host, 'scan')
|
|
1183
|
+
if os.path.exists(scan_file):
|
|
1184
|
+
try:
|
|
1185
|
+
with open(scan_file, 'w', encoding='utf-8') as f:
|
|
1186
|
+
f.write("- - -")
|
|
1187
|
+
except Exception:
|
|
1188
|
+
pass
|
|
1189
|
+
|
|
1190
|
+
# Rebind any unbound NVMe devices
|
|
1191
|
+
self._rebind_nvme_devices()
|
|
1192
|
+
|
|
1193
|
+
# Reset hw_caps stickiness for all devices so they'll be re-probed
|
|
1194
|
+
# This allows detecting hardware state changes after sleep/wake cycles
|
|
1195
|
+
if self.app.worker_manager:
|
|
1196
|
+
for partition in self.app.partitions.values():
|
|
1197
|
+
# Queue worker to re-probe this device's capabilities
|
|
1198
|
+
if partition.parent: # only need to do whole disks
|
|
1199
|
+
continue
|
|
1200
|
+
self.app.worker_manager.request_hw_caps(partition.name, force=True)
|
|
1201
|
+
# Clear cached values so UI refreshes with new probing state
|
|
1202
|
+
partition.hw_caps = ''
|
|
1203
|
+
partition.hw_nopes = ''
|
|
1204
|
+
partition.hw_caps_state = ProbeState.PENDING
|
|
948
1205
|
|
|
949
1206
|
def delete_device_ACTION(self):
|
|
950
|
-
""" DEL key -- Cause the OS to drop a
|
|
951
|
-
can be replaced sooner """
|
|
1207
|
+
""" DEL key -- Cause the OS to drop a SATA device or unbind an NVMe device
|
|
1208
|
+
so it can be replaced sooner """
|
|
952
1209
|
app = self.app
|
|
953
1210
|
ctx = app.win.get_picked_context()
|
|
954
1211
|
if ctx and hasattr(ctx, 'partition'):
|
|
955
1212
|
part = ctx.partition
|
|
956
1213
|
if not part or part.parent or not app.test_state(part, to='0%'):
|
|
957
1214
|
return
|
|
958
|
-
|
|
959
|
-
if
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1215
|
+
# NVMe unbind - write PCI address to driver unbind file
|
|
1216
|
+
if part.name.startswith('nvme'):
|
|
1217
|
+
pci_addr = self._get_nvme_pci_address(part.name)
|
|
1218
|
+
if pci_addr:
|
|
1219
|
+
unbind_path = "/sys/bus/pci/drivers/nvme/unbind"
|
|
1220
|
+
if os.path.exists(unbind_path):
|
|
1221
|
+
try:
|
|
1222
|
+
with open(unbind_path, 'w', encoding='utf-8') as f:
|
|
1223
|
+
f.write(pci_addr)
|
|
1224
|
+
return True
|
|
1225
|
+
except Exception:
|
|
1226
|
+
pass
|
|
1227
|
+
# SATA/IDE delete - write 1 to device delete file
|
|
1228
|
+
else:
|
|
1229
|
+
path = f"/sys/block/{part.name}/device/delete"
|
|
1230
|
+
if os.path.exists(path):
|
|
1231
|
+
try:
|
|
1232
|
+
with open(path, 'w', encoding='utf-8') as f:
|
|
1233
|
+
f.write("1")
|
|
1234
|
+
return True
|
|
1235
|
+
except Exception:
|
|
1236
|
+
pass
|
|
1237
|
+
|
|
1238
|
+
def _get_nvme_pci_address(self, device_name):
|
|
1239
|
+
"""Get the full PCI address for an NVMe device (e.g., '0000:01:00.0')
|
|
1240
|
+
|
|
1241
|
+
The sysfs path may contain multiple PCI addresses (bridges), so we need
|
|
1242
|
+
the last one before /nvme/ which is the actual NVMe controller.
|
|
1243
|
+
"""
|
|
1244
|
+
try:
|
|
1245
|
+
sysfs_path = f'/sys/class/block/{device_name}'
|
|
1246
|
+
if os.path.exists(sysfs_path):
|
|
1247
|
+
real_path = os.path.realpath(sysfs_path)
|
|
1248
|
+
# Find all PCI addresses and take the last one (the NVMe controller)
|
|
1249
|
+
pci_matches = re.findall(r'(0000:[0-9a-f]{2}:[0-9a-f]{2}\.[0-9a-f])', real_path, re.I)
|
|
1250
|
+
if pci_matches:
|
|
1251
|
+
return pci_matches[-1]
|
|
1252
|
+
except Exception:
|
|
1253
|
+
pass
|
|
1254
|
+
return None
|
|
1255
|
+
|
|
1256
|
+
def _rebind_nvme_devices(self):
|
|
1257
|
+
"""Find and rebind any unbound NVMe devices.
|
|
1258
|
+
|
|
1259
|
+
Scans PCI devices for NVMe controllers (class 0x010802) that have no
|
|
1260
|
+
driver bound, and attempts to bind them to the nvme driver.
|
|
1261
|
+
"""
|
|
1262
|
+
pci_devices = '/sys/bus/pci/devices'
|
|
1263
|
+
nvme_bind = '/sys/bus/pci/drivers/nvme/bind'
|
|
1264
|
+
if not os.path.exists(pci_devices) or not os.path.exists(nvme_bind):
|
|
1265
|
+
return
|
|
1266
|
+
|
|
1267
|
+
for pci_addr in os.listdir(pci_devices):
|
|
1268
|
+
device_path = os.path.join(pci_devices, pci_addr)
|
|
1269
|
+
# Check if this is an NVMe controller (class 0x010802)
|
|
1270
|
+
class_file = os.path.join(device_path, 'class')
|
|
1271
|
+
try:
|
|
1272
|
+
with open(class_file, 'r', encoding='utf-8') as f:
|
|
1273
|
+
device_class = f.read().strip()
|
|
1274
|
+
if device_class != '0x010802':
|
|
1275
|
+
continue
|
|
1276
|
+
# Check if driver is already bound
|
|
1277
|
+
driver_link = os.path.join(device_path, 'driver')
|
|
1278
|
+
if os.path.exists(driver_link):
|
|
1279
|
+
continue
|
|
1280
|
+
# Try to bind to nvme driver
|
|
1281
|
+
with open(nvme_bind, 'w', encoding='utf-8') as f:
|
|
1282
|
+
f.write(pci_addr)
|
|
1283
|
+
except Exception:
|
|
1284
|
+
pass
|
|
966
1285
|
|
|
967
1286
|
def stop_ACTION(self):
|
|
968
|
-
"""Handle 's' key"""
|
|
1287
|
+
"""Handle 's' key - stop current wipe (but not firmware wipes)"""
|
|
969
1288
|
app = self.app
|
|
970
1289
|
if app.pick_is_running:
|
|
971
1290
|
ctx = app.win.get_picked_context()
|
|
@@ -973,15 +1292,21 @@ class MainScreen(DiskWipeScreen):
|
|
|
973
1292
|
part = ctx.partition
|
|
974
1293
|
if part.state[-1] == '%':
|
|
975
1294
|
if part.job and not part.job.done:
|
|
1295
|
+
# Skip firmware wipes - they cannot be safely stopped
|
|
1296
|
+
if app._is_firmware_wipe(part):
|
|
1297
|
+
return
|
|
976
1298
|
part.job.do_abort = True
|
|
977
1299
|
|
|
978
1300
|
|
|
979
1301
|
def stop_all_ACTION(self):
|
|
980
|
-
"""Handle 'S' key"""
|
|
1302
|
+
"""Handle 'S' key - stop all wipes (but not firmware wipes)"""
|
|
981
1303
|
app = self.app
|
|
982
1304
|
for part in app.partitions.values():
|
|
983
1305
|
if part.state[-1] == '%':
|
|
984
1306
|
if part.job and not part.job.done:
|
|
1307
|
+
# Skip firmware wipes - they cannot be safely stopped
|
|
1308
|
+
if app._is_firmware_wipe(part):
|
|
1309
|
+
continue
|
|
985
1310
|
part.job.do_abort = True
|
|
986
1311
|
|
|
987
1312
|
def block_ACTION(self):
|
|
@@ -1025,6 +1350,20 @@ class HelpScreen(DiskWipeScreen):
|
|
|
1025
1350
|
spinner.show_help_nav_keys(app.win)
|
|
1026
1351
|
spinner.show_help_body(app.win)
|
|
1027
1352
|
|
|
1353
|
+
# Add CLI Options section
|
|
1354
|
+
app.win.add_body('Command Line Arguments:', attr=cs.A_UNDERLINE)
|
|
1355
|
+
opts, wid = app.opts, 8
|
|
1356
|
+
cli_options = [
|
|
1357
|
+
f'--mode: . . . . {opts.mode:<{wid}} Wipe mode',
|
|
1358
|
+
f'--passes: . . . {opts.passes:<{wid}} Passes for software wipes',
|
|
1359
|
+
f'--verify-pct: . {opts.verify_pct:<{wid}} Verification %',
|
|
1360
|
+
f'--port-serial: {opts.port_serial:<{wid}} Show port/serial/FwCAPS',
|
|
1361
|
+
f'--slowdown-stop: {opts.slowdown_stop:<{wid}} Stop if disk slows',
|
|
1362
|
+
f'--stall-timeout: {opts.stall_timeout:<{wid}} Stall timeout in sec',
|
|
1363
|
+
]
|
|
1364
|
+
for opt in cli_options:
|
|
1365
|
+
app.win.add_body(opt, attr=cs.A_DIM)
|
|
1366
|
+
|
|
1028
1367
|
|
|
1029
1368
|
|
|
1030
1369
|
class HistoryScreen(DiskWipeScreen):
|
|
@@ -1087,6 +1426,15 @@ class HistoryScreen(DiskWipeScreen):
|
|
|
1087
1426
|
|
|
1088
1427
|
def draw_screen(self):
|
|
1089
1428
|
"""Draw the history screen with structured log entries"""
|
|
1429
|
+
|
|
1430
|
+
def format_ago(timestamp):
|
|
1431
|
+
nonlocal now_dt
|
|
1432
|
+
ts = datetime.fromisoformat(timestamp)
|
|
1433
|
+
delta = now_dt - ts
|
|
1434
|
+
return Utils.ago_str(int(round(delta.total_seconds())))
|
|
1435
|
+
|
|
1436
|
+
|
|
1437
|
+
now_dt = datetime.now()
|
|
1090
1438
|
app = self.app
|
|
1091
1439
|
win = app.win
|
|
1092
1440
|
win.set_pick_mode(True)
|
|
@@ -1126,7 +1474,7 @@ class HistoryScreen(DiskWipeScreen):
|
|
|
1126
1474
|
|
|
1127
1475
|
# Header
|
|
1128
1476
|
# 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)} '
|
|
1477
|
+
header_line = f'ESC:back [e]xpand [c]opy [/]search {len(self.filtered_entries)}/{len(self.entries)} '
|
|
1130
1478
|
if search_display:
|
|
1131
1479
|
header_line += f'/ {search_display}'
|
|
1132
1480
|
else:
|
|
@@ -1141,8 +1489,17 @@ class HistoryScreen(DiskWipeScreen):
|
|
|
1141
1489
|
# Get display summary from entry
|
|
1142
1490
|
summary = entry.display_summary
|
|
1143
1491
|
|
|
1144
|
-
# Format timestamp
|
|
1145
|
-
|
|
1492
|
+
# Format timestamp based on spinner setting
|
|
1493
|
+
time_format = app.opts.hist_time_format
|
|
1494
|
+
|
|
1495
|
+
if time_format == 'ago':
|
|
1496
|
+
timestamp_display = f"{format_ago(timestamp):>6}"
|
|
1497
|
+
elif time_format == 'ago+time':
|
|
1498
|
+
ago = format_ago(timestamp)
|
|
1499
|
+
time_str = timestamp[:19]
|
|
1500
|
+
timestamp_display = f"{ago:>6} {time_str}"
|
|
1501
|
+
else: # 'time'
|
|
1502
|
+
timestamp_display = timestamp[:19] # Just the date and time part (YYYY-MM-DD HH:MM:SS)
|
|
1146
1503
|
|
|
1147
1504
|
level = entry.level
|
|
1148
1505
|
|
|
@@ -1193,13 +1550,89 @@ class HistoryScreen(DiskWipeScreen):
|
|
|
1193
1550
|
timestamp = ctx.timestamp
|
|
1194
1551
|
# Toggle between collapsed and expanded
|
|
1195
1552
|
current = self.expands.get(timestamp, False)
|
|
1196
|
-
if current:
|
|
1197
|
-
del self.expands[timestamp]
|
|
1553
|
+
if current: # Collapsing
|
|
1554
|
+
del self.expands[timestamp]
|
|
1555
|
+
# Search backwards to find first context with matching timestamp
|
|
1556
|
+
# This should be the header line of this entry
|
|
1557
|
+
test_pos = win.pick_pos - 1
|
|
1558
|
+
while test_pos >= 0:
|
|
1559
|
+
test_ctx = win.body.contexts[test_pos]
|
|
1560
|
+
if test_ctx and hasattr(test_ctx, 'timestamp') and test_ctx.timestamp == timestamp:
|
|
1561
|
+
win.pick_pos = test_pos
|
|
1562
|
+
test_pos -= 1
|
|
1563
|
+
else:
|
|
1564
|
+
return
|
|
1198
1565
|
else:
|
|
1199
|
-
|
|
1566
|
+
# Expanding - just toggle, cursor stays where it is
|
|
1567
|
+
self.expands[timestamp] = True
|
|
1200
1568
|
|
|
1201
1569
|
def filter_ACTION(self):
|
|
1202
1570
|
"""'/' key - Start incremental search"""
|
|
1203
1571
|
app = self.app
|
|
1204
1572
|
self.search_bar.start(self.prev_filter)
|
|
1205
1573
|
app.win.passthrough_mode = True
|
|
1574
|
+
|
|
1575
|
+
def copy_ACTION(self):
|
|
1576
|
+
"""'c' key - Copy current entry to clipboard or print to terminal"""
|
|
1577
|
+
from .Utils import ClipboardHelper
|
|
1578
|
+
app = self.app
|
|
1579
|
+
win = app.win
|
|
1580
|
+
ctx = win.get_picked_context()
|
|
1581
|
+
|
|
1582
|
+
if not ctx or not hasattr(ctx, 'timestamp'):
|
|
1583
|
+
return
|
|
1584
|
+
|
|
1585
|
+
# Find the entry by timestamp
|
|
1586
|
+
timestamp = ctx.timestamp
|
|
1587
|
+
entry = None
|
|
1588
|
+
for e in self.entries:
|
|
1589
|
+
if e.timestamp == timestamp:
|
|
1590
|
+
entry = e
|
|
1591
|
+
break
|
|
1592
|
+
|
|
1593
|
+
if not entry:
|
|
1594
|
+
return
|
|
1595
|
+
|
|
1596
|
+
# Format entry as JSON
|
|
1597
|
+
entry_text = json.dumps(entry.to_dict(), indent=2)
|
|
1598
|
+
|
|
1599
|
+
# Try clipboard first
|
|
1600
|
+
if ClipboardHelper.has_clipboard():
|
|
1601
|
+
success, error = ClipboardHelper.copy(entry_text)
|
|
1602
|
+
if success:
|
|
1603
|
+
# Show brief success indicator - use the header context temporarily
|
|
1604
|
+
# The message will be visible until next refresh
|
|
1605
|
+
win.add_header(f'Copied to clipboard ({ClipboardHelper.get_method_name()})')
|
|
1606
|
+
else:
|
|
1607
|
+
win.add_header(f'Clipboard error: {error}')
|
|
1608
|
+
else:
|
|
1609
|
+
# Terminal fallback: exit curses, print, wait for input
|
|
1610
|
+
self._copy_terminal_fallback(entry_text)
|
|
1611
|
+
|
|
1612
|
+
def _copy_terminal_fallback(self, text):
|
|
1613
|
+
"""Print entry to terminal when clipboard unavailable (e.g., SSH sessions)."""
|
|
1614
|
+
from .Utils import ClipboardHelper
|
|
1615
|
+
app = self.app
|
|
1616
|
+
|
|
1617
|
+
# Exit curses to use the terminal
|
|
1618
|
+
ConsoleWindow.stop_curses()
|
|
1619
|
+
os.system('clear; stty sane')
|
|
1620
|
+
|
|
1621
|
+
# Print with border
|
|
1622
|
+
print('=' * 60)
|
|
1623
|
+
print('LOG ENTRY (copy manually from terminal):')
|
|
1624
|
+
print('=' * 60)
|
|
1625
|
+
print(text)
|
|
1626
|
+
print('=' * 60)
|
|
1627
|
+
print(f'\nClipboard: {ClipboardHelper.get_method_name()}')
|
|
1628
|
+
print('\nPress ENTER to return to dwipe...')
|
|
1629
|
+
|
|
1630
|
+
# Wait for user input
|
|
1631
|
+
try:
|
|
1632
|
+
input()
|
|
1633
|
+
except EOFError:
|
|
1634
|
+
pass
|
|
1635
|
+
|
|
1636
|
+
# Restore curses
|
|
1637
|
+
ConsoleWindow.start_curses()
|
|
1638
|
+
app.win.pick_pos = app.win.pick_pos # Force position refresh
|