dwipe 2.0.1__py3-none-any.whl → 3.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dwipe/DiskWipe.py CHANGED
@@ -5,21 +5,28 @@ DiskWipe class - Main application controller/singleton
5
5
  # pylint: disable=too-many-nested-blocks,too-many-instance-attributes
6
6
  # pylint: disable=too-many-branches,too-many-statements,too-many-locals
7
7
  # pylint: disable=protected-access,too-many-return-statements
8
+ # pylint: disable=too-few-public-methods
8
9
  import os
9
10
  import sys
10
11
  import re
11
12
  import time
12
- import shutil
13
+ import threading
14
+ # import shutil
15
+ import json
13
16
  import curses as cs
14
17
  from types import SimpleNamespace
18
+ from datetime import datetime
15
19
  from console_window import (ConsoleWindow, ConsoleWindowOpts, OptionSpinner,
16
20
  IncrementalSearchBar, InlineConfirmation, Theme,
17
21
  Screen, ScreenStack, Context)
18
22
 
19
23
  from .WipeJob import WipeJob
20
24
  from .DeviceInfo import DeviceInfo
25
+ from .DeviceWorker import ProbeState
21
26
  from .Utils import Utils
22
27
  from .PersistentState import PersistentState
28
+ from .StructuredLogger import StructuredLogger
29
+ from .DeviceChangeMonitor import DeviceChangeMonitor
23
30
 
24
31
  # Screen constants
25
32
  MAIN_ST = 0
@@ -33,10 +40,26 @@ class DiskWipe:
33
40
  """Main application controller and UI manager"""
34
41
  singleton = None
35
42
 
36
- def __init__(self, opts=None):
43
+ def __init__(self, opts=None, persistent_state=None):
37
44
  DiskWipe.singleton = self
38
- self.opts = opts if opts else SimpleNamespace(debug=0, dry_run=False)
39
- self.DB = bool(self.opts.debug)
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
40
63
  self.mounts_lines = None
41
64
  self.partitions = {} # a dict of namespaces keyed by name
42
65
  self.wids = None
@@ -65,40 +88,137 @@ class DiskWipe:
65
88
  on_cancel=self._on_filter_cancel
66
89
  )
67
90
 
68
- # Initialize persistent state
69
- self.persistent_state = PersistentState()
70
- self.check_preqreqs()
71
-
72
- @staticmethod
73
- def check_preqreqs():
74
- """Check that needed programs are installed."""
75
- ok = True
76
- for prog in 'lsblk'.split():
77
- if shutil.which(prog) is None:
78
- ok = False
79
- print(f'ERROR: cannot find {prog!r} on $PATH')
80
- if not ok:
81
- sys.exit(1)
82
-
83
91
  def _start_wipe(self):
84
92
  """Start the wipe job after confirmation"""
85
- if self.confirmation.partition_name and self.confirmation.partition_name in self.partitions:
86
- part = self.partitions[self.confirmation.partition_name]
93
+ if self.confirmation.identity and self.confirmation.identity in self.partitions:
94
+ part = self.partitions[self.confirmation.identity]
87
95
  # Clear any previous verify failure message when starting wipe
88
96
  if hasattr(part, 'verify_failed_msg'):
89
97
  delattr(part, 'verify_failed_msg')
90
- part.job = WipeJob.start_job(f'/dev/{part.name}',
91
- part.size_bytes, opts=self.opts)
92
- self.job_cnt += 1
93
- self.set_state(part, to='0%')
98
+
99
+ # Get the wipe type from user's choice
100
+ # ConsoleWindow already canonicalizes case, just strip the '*' recommendation marker
101
+ wipe_type = self.confirmation.input_buffer.strip().rstrip('*')
102
+
103
+ # Check if it's a firmware wipe
104
+ if wipe_type not in ('Zero', 'Rand'):
105
+ # Firmware wipe - check if it's available
106
+ if not part.hw_caps or wipe_type not in part.hw_caps:
107
+ part.mounts = [f'⚠ Firmware wipe {wipe_type} not available']
108
+ self.confirmation.cancel()
109
+ self.win.passthrough_mode = False
110
+ return
111
+
112
+ # Get command args for this wipe type
113
+ from .DrivePreChecker import DrivePreChecker
114
+ command_args = DrivePreChecker.get_wipe_command_args(wipe_type)
115
+
116
+ # Import firmware task classes
117
+ from .FirmwareWipeTask import (NvmeWipeTask, SataWipeTask,
118
+ StandardPrecheckTask,
119
+ FirmwarePreVerifyTask, FirmwarePostVerifyTask)
120
+
121
+ # Determine task type based on device name
122
+ if part.name.startswith('nvme'):
123
+ task_class = NvmeWipeTask
124
+ else:
125
+ task_class = SataWipeTask
126
+
127
+ # Create firmware task
128
+ fw_task = task_class(
129
+ device_path=f'/dev/{part.name}',
130
+ total_size=part.size_bytes,
131
+ opts=self.opts,
132
+ command_args=command_args,
133
+ wipe_name=wipe_type
134
+ )
135
+
136
+ # Store wipe type for logging
137
+ part.wipe_type = wipe_type
138
+
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)
164
+ part.job = WipeJob(
165
+ device_path=f'/dev/{part.name}',
166
+ total_size=part.size_bytes,
167
+ opts=self.opts,
168
+ tasks=tasks
169
+ )
170
+ # Firmware wipes (SATA secure erase, NVMe sanitize) write zeros
171
+ part.job.expected_pattern = "zeroed"
172
+ part.job.thread = threading.Thread(target=part.job.run_tasks)
173
+ part.job.thread.start()
174
+
175
+ self.job_cnt += 1
176
+ self.set_state(part, to='0%')
177
+
178
+ # Clear confirmation and return early
179
+ self.confirmation.cancel()
180
+ self.win.passthrough_mode = False
181
+ return
182
+
183
+ # Construct full wipe mode (e.g., 'Zero+V', 'Rand', etc.)
184
+ if self.opts.wipe_mode == '+V':
185
+ full_wipe_mode = wipe_type + '+V'
186
+ else:
187
+ full_wipe_mode = wipe_type
188
+
189
+ # Store wipe type for later logging
190
+ part.wipe_type = wipe_type
191
+
192
+ # Temporarily set the full wipe mode
193
+ old_wipe_mode = self.opts.wipe_mode
194
+ self.opts.wipe_mode = full_wipe_mode
195
+
196
+ try:
197
+ part.job = WipeJob.start_job(f'/dev/{part.name}',
198
+ part.size_bytes, opts=self.opts)
199
+ self.job_cnt += 1
200
+ self.set_state(part, to='0%')
201
+ part.hw_nopes, part.hw_caps = {}, {}
202
+ finally:
203
+ # Restore original wipe_mode
204
+ self.opts.wipe_mode = old_wipe_mode
205
+
94
206
  # Clear confirmation state
95
207
  self.confirmation.cancel()
96
208
  self.win.passthrough_mode = False # Disable passthrough
97
209
 
98
210
  def _start_verify(self):
99
211
  """Start the verify job after confirmation"""
100
- if self.confirmation.partition_name and self.confirmation.partition_name in self.partitions:
101
- part = self.partitions[self.confirmation.partition_name]
212
+ if self.confirmation.identity and self.confirmation.identity in self.partitions:
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
+
102
222
  # Clear any previous verify failure message when starting verify
103
223
  if hasattr(part, 'verify_failed_msg'):
104
224
  delattr(part, 'verify_failed_msg')
@@ -117,12 +237,44 @@ class DiskWipe:
117
237
  """Set state of partition"""
118
238
  result = self.dev_info.set_one_state(self.partitions, ns, to=to)
119
239
 
120
- # Save lock state changes to persistent state
121
- if result and to in ('Lock', 'Unlk'):
122
- self.persistent_state.set_device_locked(ns, to == 'Lock')
240
+ # Save block state changes to persistent state
241
+ if result and to in ('Blk', 'Unbl'):
242
+ self.persistent_state.set_device_locked(ns, to == 'Blk')
123
243
 
124
244
  return result
125
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
+
126
278
  def do_key(self, key):
127
279
  """Handle keyboard input"""
128
280
  if self.exit_when_no_jobs:
@@ -137,6 +289,13 @@ class DiskWipe:
137
289
  if not key:
138
290
  return True
139
291
 
292
+ # Handle search bar input
293
+ if self.stack.curr.num == LOG_ST:
294
+ screen_obj = self.stack.get_curr_obj()
295
+ if screen_obj.search_bar.is_active:
296
+ if screen_obj.search_bar.handle_key(key):
297
+ return None # key handled by search bar
298
+
140
299
  # Handle filter bar input
141
300
  if self.filter_bar.is_active:
142
301
  if self.filter_bar.handle_key(key):
@@ -146,9 +305,9 @@ class DiskWipe:
146
305
  if self.confirmation.active:
147
306
  result = self.confirmation.handle_key(key)
148
307
  if result == 'confirmed':
149
- if self.confirmation.confirm_type == 'wipe':
308
+ if self.confirmation.action_type == 'wipe':
150
309
  self._start_wipe()
151
- elif self.confirmation.confirm_type == 'verify':
310
+ elif self.confirmation.action_type == 'verify':
152
311
  self._start_verify()
153
312
  elif result == 'cancelled':
154
313
  self.confirmation.cancel()
@@ -181,17 +340,7 @@ class DiskWipe:
181
340
  line += ' [S]top' if self.job_cnt > 0 else ''
182
341
  line = f'{line:<20} '
183
342
  line += self.filter_bar.get_display_string(prefix=' /') or ' /'
184
- # Show mode spinner with key
185
- line += f' [m]ode={self.opts.wipe_mode}'
186
- # Show passes spinner with key
187
- line += f' [P]ass={self.opts.passes}'
188
- # Show verification percentage spinner with key
189
- line += f' [V]pct={self.opts.verify_pct}%'
190
- line += f' [p]ort'
191
- line += ' '
192
- if self.opts.dry_run:
193
- line += ' DRY-RUN'
194
- line += ' [h]ist [t]heme ?:help [q]uit'
343
+ line += ' [r]escan [h]ist [t]heme ?:help [q]uit'
195
344
  return line[1:]
196
345
 
197
346
  def get_actions(self, part):
@@ -202,20 +351,27 @@ class DiskWipe:
202
351
  part = ctx.partition
203
352
  name = part.name
204
353
  self.pick_is_running = bool(part.job)
205
- if self.test_state(part, to='STOP'):
354
+ # Show stop action only for non-firmware wipes
355
+ if self.test_state(part, to='STOP') and not self._is_firmware_wipe(part):
206
356
  actions['s'] = 'stop'
207
357
  elif self.test_state(part, to='0%'):
208
358
  actions['w'] = 'wipe'
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'):
361
+ actions['DEL'] = 'DEL'
209
362
  # Can verify:
210
- # 1. Anything with wipe markers (states 's' or 'W')
363
+ # 1. Anything with wipe markers (states 's' or 'W') - EXCEPT firmware wipes
211
364
  # 2. Unmarked whole disks (no parent, state '-' or '^') WITHOUT partitions that have filesystems
212
365
  # 3. Unmarked partitions without filesystems (has parent, state '-' or '^', no fstype)
213
366
  # 4. Only if verify_pct > 0
214
367
  # This prevents verifying filesystems which is nonsensical
368
+ # NOTE: Firmware wipes have built-in pre/post verification, so manual verify is disallowed
215
369
  verify_pct = getattr(self.opts, 'verify_pct', 0)
216
370
  if not part.job and verify_pct > 0:
217
371
  if part.state in ('s', 'W'):
218
- actions['v'] = 'verify'
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'
219
375
  elif part.state in ('-', '^'):
220
376
  # For whole disks (no parent), only allow verify if no partitions have filesystems
221
377
  # For partitions, only allow if no filesystem
@@ -230,10 +386,10 @@ class DiskWipe:
230
386
  elif not part.fstype:
231
387
  # Partition without filesystem
232
388
  actions['v'] = 'verify'
233
- if self.test_state(part, to='Lock'):
234
- actions['l'] = 'lock'
235
- if self.test_state(part, to='Unlk'):
236
- actions['l'] = 'unlk'
389
+ if self.test_state(part, to='Blk'):
390
+ actions['b'] = 'block'
391
+ if self.test_state(part, to='Unbl'):
392
+ actions['b'] = 'unblk'
237
393
  return name, actions
238
394
 
239
395
  def _on_filter_change(self, text):
@@ -268,10 +424,57 @@ class DiskWipe:
268
424
  self.prev_filter = ''
269
425
  self.win.passthrough_mode = False
270
426
 
427
+ def get_hw_caps_when_needed(self):
428
+ """ Look for wipeable disks w/o hardware info """
429
+ if not self.dev_info:
430
+ return
431
+ from .DeviceWorker import ProbeState
432
+ for ns in self.partitions.values():
433
+ if ns.parent:
434
+ continue
435
+ # if ns.port.startswith('USB'):
436
+ # continue
437
+ if ns.name[:2] not in ('nv', 'sd', 'hd'):
438
+ continue
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:
445
+ continue
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):
471
+ self.dev_info.get_hw_capabilities(ns)
472
+
271
473
  def main_loop(self):
272
474
  """Main event loop"""
273
475
 
274
476
  # Create screen instances
477
+ ThemeScreen = Theme.create_picker_screen(DiskWipeScreen)
275
478
  self.screens = {
276
479
  MAIN_ST: MainScreen(self),
277
480
  HELP_ST: HelpScreen(self),
@@ -284,10 +487,29 @@ class DiskWipe:
284
487
  head_line=True,
285
488
  body_rows=200,
286
489
  head_rows=4,
490
+ min_cols_rows=(60,10),
287
491
  # keys=self.spin.keys ^ other_keys,
288
492
  pick_attr=cs.A_REVERSE, # Use reverse video for pick highlighting
289
493
  ctrl_c_terminates=False,
290
494
  )
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())
503
+ # Initialize device info and pick range before first draw
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)
291
513
 
292
514
  self.win = ConsoleWindow(opts=win_opts)
293
515
  # Initialize screen stack
@@ -296,13 +518,8 @@ class DiskWipe:
296
518
  spin = self.spin = OptionSpinner(stack=self.stack)
297
519
  spin.default_obj = self.opts
298
520
  spin.add_key('dense', 'D - dense/spaced view', vals=[False, True])
299
- spin.add_key('port_serial', 'p - disk port info', vals=[False, True])
300
- spin.add_key('slowdown_stop', 'L - stop if disk slows Nx', vals=[16, 64, 256, 0, 4])
301
- spin.add_key('stall_timeout', 'T - stall timeout (sec)', vals=[60, 120, 300, 600, 0,])
302
- spin.add_key('verify_pct', 'V - verification %', vals=[0, 2, 5, 10, 25, 50, 100])
303
- spin.add_key('passes', 'P - wipe pass count', vals=[1, 2, 4])
304
- spin.add_key('wipe_mode', 'm - wipe mode', vals=['Zero', 'Zero+V', 'Rand', 'Rand+V'])
305
- spin.add_key('confirmation', 'c - confirmation mode', vals=['YES', 'yes', 'device', 'Y', 'y'])
521
+ spin.add_key('hist_time_format', 'a - time format',
522
+ vals=['ago+time', 'ago', 'time'], scope=LOG_ST)
306
523
 
307
524
  spin.add_key('quit', 'q,x - quit program', keys='qx', genre='action')
308
525
  spin.add_key('screen_escape', 'ESC- back one screen',
@@ -312,7 +529,11 @@ class DiskWipe:
312
529
  spin.add_key('wipe', 'w - wipe device', genre='action')
313
530
  spin.add_key('verify', 'v - verify device', genre='action')
314
531
  spin.add_key('stop', 's - stop wipe', genre='action')
315
- spin.add_key('lock', 'l - lock/unlock disk', genre='action')
532
+ spin.add_key('block', 'b - block/unblock disk', genre='action')
533
+ spin.add_key('delete_device', 'DEL - remove/unbind disk from system',
534
+ genre='action', keys=(cs.KEY_DC))
535
+ spin.add_key('scan_all_devices', 'r - rescan devices and recheck capabilities',
536
+ genre='action', scope=MAIN_ST)
316
537
  spin.add_key('stop_all', 'S - stop ALL wipes', genre='action')
317
538
  spin.add_key('help', '? - show help screen', genre='action')
318
539
  spin.add_key('history', 'h - show wipe history', genre='action')
@@ -320,78 +541,135 @@ class DiskWipe:
320
541
  spin.add_key('theme_screen', 't - theme picker', genre='action', scope=MAIN_ST)
321
542
  spin.add_key('spin_theme', 't - theme', genre='action', scope=THEME_ST)
322
543
  spin.add_key('header_mode', '_ - header style', vals=['Underline', 'Reverse', 'Off'])
323
- self.opts.theme = ''
324
- self.persistent_state.restore_updated_opts(self.opts)
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)
546
+ spin.add_key('show_keys', 'K - show keys (demo mode)', genre='action')
547
+ # Load theme from persistent state
548
+ self.opts.theme = self.persistent_state.state.get('theme', '')
325
549
  Theme.set(self.opts.theme)
326
550
  self.win.set_handled_keys(self.spin.keys)
327
551
 
552
+ # Background device change monitor started above
328
553
 
329
- # self.opts.name = "[hit 'n' to enter name]"
330
-
331
- # Initialize device info and pick range before first draw
332
- info = DeviceInfo(opts=self.opts, persistent_state=self.persistent_state)
333
- self.partitions = info.assemble_partitions(self.partitions)
334
- self.dev_info = info
554
+ # self.dev_info already set during startup probe above
555
+ self.worker_manager = worker_manager
335
556
  pick_range = info.get_pick_range()
336
557
  self.win.set_pick_range(pick_range[0], pick_range[1])
337
558
 
338
- check_devices_mono = time.monotonic()
339
- while True:
340
- # Draw current screen
341
- current_screen = self.screens[self.stack.curr.num]
342
- current_screen.draw_screen()
343
- self.win.render()
344
-
345
- seconds = 3.0
346
- _ = self.do_key(self.win.prompt(seconds=seconds))
347
-
348
- # Handle actions using perform_actions
349
- self.stack.perform_actions(spin)
350
-
351
- if time.monotonic() - check_devices_mono > (seconds * 0.95):
352
- info = DeviceInfo(opts=self.opts, persistent_state=self.persistent_state)
353
- self.partitions = info.assemble_partitions(self.partitions)
354
- self.dev_info = info
355
- # Update pick range to highlight NAME through SIZE fields
356
- pick_range = info.get_pick_range()
357
- self.win.set_pick_range(pick_range[0], pick_range[1])
358
- check_devices_mono = time.monotonic()
359
559
 
360
- # Save any persistent state changes
361
- self.persistent_state.save_updated_opts(self.opts)
362
- self.persistent_state.sync()
560
+ check_devices_mono = time.monotonic()
561
+ cached_worker_state = {} # Track marker/hw_caps state to detect updates
363
562
 
364
- self.win.clear()
563
+ try:
564
+ while True:
565
+ # Draw current screen
566
+ current_screen = self.screens[self.stack.curr.num]
567
+ current_screen.draw_screen()
568
+ self.win.render()
569
+
570
+ # Main thread timeout for responsive UI (background monitor checks every 0.2s)
571
+ _ = self.do_key(self.win.prompt(seconds=0.25))
572
+
573
+ # Handle actions using perform_actions
574
+ self.stack.perform_actions(spin)
575
+
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()
582
+ time_since_refresh = time.monotonic() - check_devices_mono
583
+
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)
605
+ self.dev_info = info
606
+ # Update pick range to highlight NAME through SIZE fields
607
+ pick_range = info.get_pick_range()
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()
611
+ check_devices_mono = time.monotonic()
612
+ # Update cached state after refresh
613
+ cached_worker_state = current_worker_state.copy()
614
+
615
+ # Save any persistent state changes
616
+ self.persistent_state.save_updated_opts(self.opts)
617
+ self.persistent_state.sync()
618
+
619
+ self.win.clear()
620
+ finally:
621
+ # Clean up monitor thread on exit
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()
365
626
 
366
627
  class DiskWipeScreen(Screen):
367
628
  """ TBD """
368
629
  app: DiskWipe
630
+ refresh_seconds = 3.0 # Default refresh rate for screens
369
631
 
370
632
  def screen_escape_ACTION(self):
371
633
  """ return to main screen """
372
634
  self.app.stack.pop()
373
635
 
636
+ def show_keys_ACTION(self):
637
+ """ Show last key for demo"""
638
+ self.app.win.set_demo_mode(enabled=None) # toggle it
639
+
374
640
  class MainScreen(DiskWipeScreen):
375
641
  """Main device list screen"""
376
642
 
377
- def _port_serial_line(self, port, serial):
643
+ def __init__(self, app):
644
+ super().__init__(app)
645
+ self.app = app
646
+ self.persist_port_serial = set()
647
+
648
+
649
+ def _port_serial_line(self, partition, has_children=True):
378
650
  wids = self.app.wids
379
- wid = wids.state if wids else 5
380
651
  sep = ' '
381
- return f'{"":>{wid}}{sep}│ └────── {port:<12} {serial}'
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 ''
382
655
 
383
- def draw_screen(self):
384
- """Draw the main device list"""
385
- app = self.app
656
+ # Get column widths (with defaults if wids not yet initialized)
657
+ wid_state = wids.state if wids else 5
386
658
 
387
- def wanted(name):
388
- return not app.filter or app.filter.search(name)
659
+ # Use corner └ if no children below, or vertical │ if there are children to connect to
660
+ connector = '│' if has_children else ' '
389
661
 
390
- app.win.set_pick_mode(True)
662
+ # Build base line: state padding + connector + port/serial
663
+ base = f'{"":>{wid_state}}{sep}{connector} └────── {port:<12} {serial}'
391
664
 
392
- # First pass: process jobs and collect visible partitions
393
- visible_partitions = []
394
- for name, partition in app.partitions.items():
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():
395
673
  partition.line = None
396
674
  if partition.job:
397
675
  if partition.job.done:
@@ -474,6 +752,9 @@ class MainScreen(DiskWipeScreen):
474
752
  else:
475
753
  verify_detail = verify_result
476
754
 
755
+ # Structured logging
756
+ Utils.log_wipe_structured(app.partitions, partition, partition.job)
757
+ # Legacy text log (keep for compatibility)
477
758
  Utils.log_wipe(partition.name, partition.size_bytes, 'Vrfy', result, elapsed,
478
759
  uuid=partition.uuid, verify_result=verify_detail)
479
760
  app.job_cnt -= 1
@@ -483,6 +764,8 @@ class MainScreen(DiskWipeScreen):
483
764
  partition.state = partition.dflt
484
765
  partition.job = None
485
766
  partition.marker_checked = False # Reset to "dont-know" - will re-read on next scan
767
+ partition.marker = '' # Clear stale marker string to avoid showing old data during re-read
768
+ partition.monitor_marker = False # Stop monitoring verify job
486
769
  else:
487
770
  # Wipe job completed (with or without auto-verify)
488
771
  # Check if stopped during verify phase (after successful write)
@@ -515,12 +798,15 @@ class MainScreen(DiskWipeScreen):
515
798
  # Log the wipe operation
516
799
  elapsed = time.monotonic() - partition.job.start_mono
517
800
  result = 'stopped' if partition.job.do_abort else 'completed'
518
- # Extract base mode (remove '+V' suffix if present)
519
- mode = app.opts.wipe_mode.replace('+V', '')
801
+ # Get the wipe type that was used (stored when wipe was started)
802
+ mode = getattr(partition, 'wipe_type', 'Unknown')
520
803
  # Calculate percentage if stopped
521
804
  pct = None
522
805
  if partition.job.do_abort and partition.job.total_size > 0:
523
806
  pct = int((partition.job.total_written / partition.job.total_size) * 100)
807
+ # Structured logging
808
+ Utils.log_wipe_structured(app.partitions, partition, partition.job, mode=mode)
809
+ # Legacy text log (keep for compatibility)
524
810
  # Only pass label/fstype for stopped wipes (not completed)
525
811
  if result == 'stopped':
526
812
  Utils.log_wipe(partition.name, partition.size_bytes, mode, result, elapsed,
@@ -553,41 +839,106 @@ class MainScreen(DiskWipeScreen):
553
839
  else:
554
840
  verify_detail = verify_result
555
841
 
842
+ # Note: Structured logging for verify was already logged above as part of the wipe
843
+ # This is just logging the separate verify phase stats to the legacy log
556
844
  Utils.log_wipe(partition.name, partition.size_bytes, 'Vrfy', result, verify_elapsed,
557
845
  uuid=partition.uuid, verify_result=verify_detail)
558
846
 
559
847
  if partition.job.exception:
560
- app.win.stop_curses()
561
- print('\n\n\n========== ALERT =========\n')
562
- print(f' FAILED: wipe {repr(partition.name)}')
563
- print(partition.job.exception)
564
- input('\n\n===== Press ENTER to continue ====> ')
565
- app.win._start_curses()
848
+ app.win.alert(
849
+ message=f'FAILED: wipe {repr(partition.name)}\n{partition.job.exception}',
850
+ title='ALERT'
851
+ )
566
852
 
567
853
  partition.job = None
568
854
  partition.marker_checked = False # Reset to "dont-know" - will re-read on next scan
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
569
857
  if partition.job:
570
- elapsed, pct, rate, until = partition.job.get_status()
858
+ elapsed, pct, rate, until, more_state = partition.job.get_status()
859
+
860
+ # Get task display name (Zero, Rand, Crypto, Verify, etc.)
861
+ task_name = ""
862
+ if partition.job.current_task:
863
+ task_name = partition.job.current_task.get_display_name()
571
864
 
572
865
  # FLUSH goes in mounts column, not state
573
866
  if pct.startswith('FLUSH'):
574
867
  partition.state = partition.dflt # Keep default state (s, W, etc)
575
868
  if rate and until:
576
- partition.mounts = [f'{pct} {elapsed} -{until} {rate}']
869
+ partition.mounts = [f'{task_name} {pct} {elapsed} -{until} {rate}']
577
870
  else:
578
- partition.mounts = [f'{pct} {elapsed}']
871
+ partition.mounts = [f'{task_name} {pct} {elapsed}']
872
+ if more_state:
873
+ partition.mounts[0] += f' {more_state}'
579
874
  else:
580
875
  partition.state = pct
581
- slowdown = partition.job.max_slowdown_ratio # temp?
582
- stall = partition.job.max_stall_secs # temp
583
- partition.mounts = [f'{elapsed} -{until} {rate} ÷{slowdown} 𝚫{Utils.ago_str(stall)}']
876
+ # Build progress line with task name
877
+ progress_parts = [task_name, elapsed, f'-{until}', rate]
878
+
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):
884
+ slowdown = partition.job.max_slowdown_ratio
885
+ stall = partition.job.max_stall_secs
886
+ progress_parts.extend([f'÷{slowdown}', f'𝚫{Utils.ago_str(stall)}'])
887
+
888
+ if more_state:
889
+ progress_parts.append(more_state)
890
+
891
+ partition.mounts = [' '.join(progress_parts)]
584
892
 
585
893
  if partition.parent and partition.parent in app.partitions and (
586
- app.partitions[partition.parent].state == 'Lock'):
894
+ app.partitions[partition.parent].state in ('Blk', 'iBlk')):
587
895
  continue
588
896
 
589
- if wanted(name) or partition.job:
590
- visible_partitions.append(partition)
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)
591
942
 
592
943
  # Re-infer parent states (like 'Busy') after updating child job states
593
944
  DeviceInfo.set_all_states(app.partitions)
@@ -618,38 +969,65 @@ class MainScreen(DiskWipeScreen):
618
969
  # Create context with partition reference
619
970
  ctx = Context(genre='disk' if partition.parent is None else 'partition',
620
971
  partition=partition)
621
- app.win.add_body(partition.line, attr=attr, context=ctx)
622
- if partition.parent is None and app.opts.port_serial:
623
- line = self._port_serial_line(partition.port, partition.serial)
624
- app.win.add_body(line, attr=attr, context=Context(genre='DECOR'))
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)
982
+ if partition.parent is None and app.opts.port_serial != 'Off':
983
+ doit = bool(app.opts.port_serial == 'On')
984
+ if not doit:
985
+ doit = bool(partition.name in self.persist_port_serial)
986
+ if not doit and app.test_state(partition, to='0%'):
987
+ doit = True
988
+ self.persist_port_serial.add(partition.name)
989
+ if doit:
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'))
625
995
 
626
996
  # Show inline confirmation prompt if this is the partition being confirmed
627
- if app.confirmation.active and app.confirmation.partition_name == partition.name:
997
+ if app.confirmation.active and app.confirmation.identity == partition.name:
628
998
  # Build confirmation message
629
- if app.confirmation.confirm_type == 'wipe':
630
- msg = f'⚠️ WIPE {partition.name} ({Utils.human(partition.size_bytes)})'
999
+ if app.confirmation.action_type == 'wipe':
1000
+ msg = f'⚠️ WIPE'
631
1001
  else: # verify
632
- msg = f'⚠️ VERIFY {partition.name} ({Utils.human(partition.size_bytes)}) - writes marker'
633
-
634
- # Add mode-specific prompt
635
- if app.confirmation.mode == 'Y':
636
- msg += " - Press 'Y' or ESC"
637
- elif app.confirmation.mode == 'y':
638
- msg += " - Press 'y' or ESC"
639
- elif app.confirmation.mode == 'YES':
640
- msg += f" - Type 'YES': {app.confirmation.input_buffer}_"
641
- elif app.confirmation.mode == 'yes':
642
- msg += f" - Type 'yes': {app.confirmation.input_buffer}_"
643
- elif app.confirmation.mode == 'device':
644
- msg += f" - Type '{partition.name}': {app.confirmation.input_buffer}_"
1002
+ msg = f'⚠️ VERIFY [writes marker]'
1003
+
1004
+ # Add mode-specific prompt (base message without input)
1005
+ if app.confirmation.mode == 'yes':
1006
+ msg += " - Type 'yes': "
1007
+ elif app.confirmation.mode == 'identity':
1008
+ msg += f" - Type '{partition.name}': "
1009
+ elif app.confirmation.mode == 'choices':
1010
+ choices_str = ','.join(app.confirmation.choices)
1011
+ msg += f" Choice ({choices_str}): "
645
1012
 
646
1013
  # Position message at fixed column (reduced from 28 to 20)
647
- msg = ' ' * 20 + msg
1014
+ msg = ' ' * 5 + msg
648
1015
 
649
- # Add confirmation message as DECOR (non-pickable)
1016
+ # Add confirmation message base as DECOR (non-pickable)
650
1017
  app.win.add_body(msg, attr=cs.color_pair(Theme.DANGER) | cs.A_BOLD,
651
1018
  context=Context(genre='DECOR'))
652
1019
 
1020
+ # Add input or hint on same line
1021
+ if app.confirmation.input_buffer:
1022
+ # Show current input with cursor
1023
+ app.win.add_body(app.confirmation.input_buffer + '_',
1024
+ attr=cs.color_pair(Theme.DANGER) | cs.A_BOLD,
1025
+ resume=True)
1026
+ else:
1027
+ # Show hint in dimmed italic
1028
+ hint = app.confirmation.get_hint()
1029
+ app.win.add_body(hint, attr=cs.A_DIM | cs.A_ITALIC, resume=True)
1030
+
653
1031
  app.win.add_fancy_header(app.get_keys_line(), mode=app.opts.header_mode)
654
1032
 
655
1033
  app.win.add_header(app.dev_info.head_str, attr=cs.A_DIM)
@@ -685,9 +1063,18 @@ class MainScreen(DiskWipeScreen):
685
1063
  """Handle quit action (q or x key pressed)"""
686
1064
  app = self.app
687
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
+
688
1072
  def stop_if_idle(part):
689
1073
  if part.state[-1] == '%':
690
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
691
1078
  part.job.do_abort = True
692
1079
  return 1 if part.job else 0
693
1080
 
@@ -718,7 +1105,26 @@ class MainScreen(DiskWipeScreen):
718
1105
  part = ctx.partition
719
1106
  if app.test_state(part, to='0%'):
720
1107
  self.clear_hotswap_marker(part)
721
- app.confirmation.start('wipe', part.name, app.opts.confirmation)
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
1111
+ choices = ['Zero', 'Rand']
1112
+ fw_modes = []
1113
+ if part.hw_caps:
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)
1126
+ app.confirmation.start(action_type='wipe',
1127
+ identity=part.name, mode='choices', choices=choices)
722
1128
  app.win.passthrough_mode = True
723
1129
 
724
1130
  def verify_ACTION(self):
@@ -730,13 +1136,20 @@ class MainScreen(DiskWipeScreen):
730
1136
  # Use get_actions() to ensure we use the same logic as the header display
731
1137
  _, actions = app.get_actions(part)
732
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
+
733
1145
  self.clear_hotswap_marker(part)
734
1146
  # Check if this is an unmarked disk/partition (potential data loss risk)
735
1147
  # Whole disks (no parent) or partitions without filesystems need confirmation
736
1148
  is_unmarked = part.state == '-' and (not part.parent or not part.fstype)
737
1149
  if is_unmarked:
738
1150
  # Require confirmation for unmarked partitions
739
- app.confirmation.start('verify', part.name, app.opts.confirmation)
1151
+ app.confirmation.start(action_type='verify',
1152
+ identity=part.name, mode="yes")
740
1153
  app.win.passthrough_mode = True
741
1154
  else:
742
1155
  # Marked partition - proceed directly
@@ -747,8 +1160,122 @@ class MainScreen(DiskWipeScreen):
747
1160
  part.size_bytes, opts=app.opts)
748
1161
  app.job_cnt += 1
749
1162
 
1163
+ def scan_all_devices_ACTION(self):
1164
+ """ Trigger a re-scan of all devices to make the appear
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)
1170
+ base_path = '/sys/class/scsi_host'
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
1196
+
1197
+ def delete_device_ACTION(self):
1198
+ """ DEL key -- Cause the OS to drop a SATA device or unbind an NVMe device
1199
+ so it can be replaced sooner """
1200
+ app = self.app
1201
+ ctx = app.win.get_picked_context()
1202
+ if ctx and hasattr(ctx, 'partition'):
1203
+ part = ctx.partition
1204
+ if not part or part.parent or not app.test_state(part, to='0%'):
1205
+ return
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
1276
+
750
1277
  def stop_ACTION(self):
751
- """Handle 's' key"""
1278
+ """Handle 's' key - stop current wipe (but not firmware wipes)"""
752
1279
  app = self.app
753
1280
  if app.pick_is_running:
754
1281
  ctx = app.win.get_picked_context()
@@ -756,24 +1283,31 @@ class MainScreen(DiskWipeScreen):
756
1283
  part = ctx.partition
757
1284
  if part.state[-1] == '%':
758
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
759
1289
  part.job.do_abort = True
760
1290
 
1291
+
761
1292
  def stop_all_ACTION(self):
762
- """Handle 'S' key"""
1293
+ """Handle 'S' key - stop all wipes (but not firmware wipes)"""
763
1294
  app = self.app
764
1295
  for part in app.partitions.values():
765
1296
  if part.state[-1] == '%':
766
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
767
1301
  part.job.do_abort = True
768
1302
 
769
- def lock_ACTION(self):
770
- """Handle 'l' key"""
1303
+ def block_ACTION(self):
1304
+ """Handle 'b' key"""
771
1305
  app = self.app
772
1306
  ctx = app.win.get_picked_context()
773
1307
  if ctx and hasattr(ctx, 'partition'):
774
1308
  part = ctx.partition
775
1309
  self.clear_hotswap_marker(part)
776
- app.set_state(part, 'Unlk' if part.state == 'Lock' else 'Lock')
1310
+ app.set_state(part, 'Unbl' if part.state == 'Blk' else 'Blk')
777
1311
 
778
1312
  def help_ACTION(self):
779
1313
  """Handle '?' key - push help screen"""
@@ -807,84 +1341,289 @@ class HelpScreen(DiskWipeScreen):
807
1341
  spinner.show_help_nav_keys(app.win)
808
1342
  spinner.show_help_body(app.win)
809
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
+
810
1358
 
811
1359
 
812
1360
  class HistoryScreen(DiskWipeScreen):
813
- """History/log screen showing wipe history"""
1361
+ """History/log screen showing structured log entries with expand/collapse functionality"""
1362
+
1363
+ refresh_seconds = 60.0 # Slower refresh for history screen to allow copy/paste
1364
+
1365
+ def __init__(self, app):
1366
+ super().__init__(app)
1367
+ self.expands = {} # Maps timestamp -> True (expanded) or False (collapsed)
1368
+ self.entries = [] # Cached log entries (all entries before filtering)
1369
+ self.filtered_entries = [] # Entries after search filtering
1370
+ self.window_of_logs = None # Window of log entries (OrderedDict)
1371
+ self.window_state = None # Window state for incremental reads
1372
+ self.search_matches = set() # Set of timestamps with deep-only matches in JSON
1373
+ self.prev_filter = ''
1374
+
1375
+ # Setup search bar
1376
+ self.search_bar = IncrementalSearchBar(
1377
+ on_change=self._on_search_change,
1378
+ on_accept=self._on_search_accept,
1379
+ on_cancel=self._on_search_cancel
1380
+ )
1381
+
1382
+ def _on_search_change(self, text):
1383
+ """Called when search text changes - filter entries incrementally."""
1384
+ self._filter_entries(text)
1385
+
1386
+ def _on_search_accept(self, text):
1387
+ """Called when ENTER pressed in search - keep filter active, exit input mode."""
1388
+ self.app.win.passthrough_mode = False
1389
+ self.prev_filter = text
1390
+
1391
+ def _on_search_cancel(self, original_text):
1392
+ """Called when ESC pressed in search - restore and exit search mode."""
1393
+ self._filter_entries(original_text)
1394
+ self.app.win.passthrough_mode = False
1395
+
1396
+ def _filter_entries(self, search_text):
1397
+ """Filter entries based on search text (shallow or deep)."""
1398
+ if not search_text:
1399
+ self.filtered_entries = self.entries
1400
+ self.search_matches = set()
1401
+ return
1402
+
1403
+ # Deep search mode if starts with /
1404
+ deep_search = search_text.startswith('/')
1405
+ pattern = search_text[1:] if deep_search else search_text
1406
+
1407
+ if not pattern:
1408
+ self.filtered_entries = self.entries
1409
+ self.search_matches = set()
1410
+ return
1411
+
1412
+ # Use StructuredLogger's filter method
1413
+ # logger = Utils.get_logger()
1414
+ self.filtered_entries, self.search_matches = StructuredLogger.filter_entries(
1415
+ self.entries, pattern, deep=deep_search
1416
+ )
814
1417
 
815
1418
  def draw_screen(self):
816
- """Draw the history screen"""
817
- app = self.app
818
- # spinner = self.get_spinner()
1419
+ """Draw the history screen with structured log entries"""
819
1420
 
820
- app.win.set_pick_mode(False)
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())))
821
1426
 
822
- # Add header
823
- app.win.add_header('WIPE HISTORY (newest first)', attr=cs.A_BOLD)
824
- app.win.add_header(' Press ESC to return', resume=True)
825
1427
 
826
- # Read and display log file in reverse order
827
- log_path = Utils.get_log_path()
828
- if log_path.exists():
829
- try:
830
- with open(log_path, 'r', encoding='utf-8') as f:
831
- lines = f.readlines()
832
-
833
- # Show in reverse order (newest first)
834
- for line in reversed(lines):
835
- app.win.put_body(line.rstrip())
836
- except Exception as e:
837
- app.win.put_body(f'Error reading log: {e}')
1428
+ now_dt = datetime.now()
1429
+ app = self.app
1430
+ win = app.win
1431
+ win.set_pick_mode(True)
1432
+
1433
+ # Get window of log entries (chronological order - eldest to youngest)
1434
+ logger = Utils.get_logger()
1435
+ if self.window_of_logs is None:
1436
+ self.window_of_logs, self.window_state = logger.get_window_of_entries(window_size=1000)
1437
+ else:
1438
+ # Refresh window with any new entries
1439
+ self.window_of_logs, self.window_state = logger.refresh_window(
1440
+ self.window_of_logs, self.window_state, window_size=1000
1441
+ )
1442
+
1443
+ # Convert to list in reverse order (newest first for display)
1444
+ self.entries = list(reversed(list(self.window_of_logs.values())))
1445
+
1446
+ # Clean up self.expands: remove any timestamps that are no longer in entries
1447
+ valid_timestamps = {entry.timestamp for entry in self.entries}
1448
+ self.expands = {ts: state for ts, state in self.expands.items() if ts in valid_timestamps}
1449
+
1450
+ # Apply search filter if active
1451
+ if not self.search_bar.text:
1452
+ self.filtered_entries = self.entries
1453
+ self.search_matches = set()
1454
+
1455
+ # Count by level in filtered results
1456
+ level_counts = {}
1457
+ for e in self.filtered_entries:
1458
+ level_counts[e.level] = level_counts.get(e.level, 0) + 1
1459
+
1460
+ # Build search display string
1461
+ search_display = self.search_bar.get_display_string(prefix='', suffix='')
1462
+
1463
+ # Build level summary for header
1464
+ # level_summary = ' '.join(f'{lvl}:{cnt}' for lvl, cnt in sorted(level_counts.items()))
1465
+
1466
+ # Header
1467
+ # header_line = f'ESC:back [e]xpand [/]search {len(self.filtered_entries)}/{len(self.entries)} ({level_summary}) '
1468
+ header_line = f'ESC:back [e]xpand [c]opy [/]search {len(self.filtered_entries)}/{len(self.entries)} '
1469
+ if search_display:
1470
+ header_line += f'/ {search_display}'
838
1471
  else:
839
- app.win.put_body('No wipe history found.')
840
- app.win.put_body('')
841
- app.win.put_body(f'Log file will be created at: {log_path}')
1472
+ header_line += '/'
1473
+ win.add_header(header_line)
1474
+ win.add_header(f'Log: {logger.log_file}')
1475
+
1476
+ # Build display
1477
+ for entry in self.filtered_entries:
1478
+ timestamp = entry.timestamp
1479
+
1480
+ # Get display summary from entry
1481
+ summary = entry.display_summary
1482
+
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)
1494
+
1495
+ level = entry.level
1496
+
1497
+ # Add deep match indicator if this entry matched only in JSON
1498
+ deep_indicator = " *" if timestamp in self.search_matches else ""
1499
+
1500
+ # Choose color based on log level
1501
+ if level == 'ERR':
1502
+ level_attr = cs.color_pair(Theme.ERROR) | cs.A_BOLD
1503
+ elif level in ('WIPE_STOPPED', 'VERIFY_STOPPED'):
1504
+ level_attr = cs.color_pair(Theme.WARNING) | cs.A_BOLD
1505
+ elif level in ('WIPE_COMPLETE', 'VERIFY_COMPLETE'):
1506
+ level_attr = cs.color_pair(Theme.SUCCESS) | cs.A_BOLD
1507
+ else:
1508
+ level_attr = cs.A_BOLD
1509
+
1510
+ line = f"{timestamp_display} {summary}{deep_indicator}"
1511
+ win.add_body(line, attr=level_attr, context=Context("header", timestamp=timestamp))
1512
+
1513
+ # Handle expansion - show the structured data
1514
+ if self.expands.get(timestamp, False):
1515
+ # Show the full entry data as formatted JSON
1516
+ try:
1517
+ data_dict = entry.to_dict()
1518
+ # Format just the 'data' field if it exists, otherwise show all
1519
+ if 'data' in data_dict and data_dict['data']:
1520
+ formatted = json.dumps(data_dict['data'], indent=2)
1521
+ else:
1522
+ formatted = json.dumps(data_dict, indent=2)
842
1523
 
1524
+ lines = formatted.split('\n')
1525
+ for line in lines:
1526
+ win.add_body(f" {line}", context=Context("body", timestamp=timestamp))
843
1527
 
844
- class ThemeScreen(DiskWipeScreen):
845
- """Theme preview screen showing all available themes with color examples"""
846
- prev_theme = ""
1528
+ except Exception as e:
1529
+ win.add_body(f" (error formatting: {e})", attr=cs.A_DIM)
847
1530
 
848
- def draw_screen(self):
849
- """Draw the theme screen with color examples for all themes"""
1531
+ # Empty line between entries
1532
+ win.add_body("", context=Context("DECOR"))
1533
+
1534
+ def expand_ACTION(self):
1535
+ """'e' key - Expand/collapse current entry"""
850
1536
  app = self.app
1537
+ win = app.win
1538
+ ctx = win.get_picked_context()
1539
+
1540
+ if ctx and hasattr(ctx, 'timestamp'):
1541
+ timestamp = ctx.timestamp
1542
+ # Toggle between collapsed and expanded
1543
+ current = self.expands.get(timestamp, False)
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
1556
+ else:
1557
+ # Expanding - just toggle, cursor stays where it is
1558
+ self.expands[timestamp] = True
851
1559
 
852
- app.win.set_pick_mode(False)
1560
+ def filter_ACTION(self):
1561
+ """'/' key - Start incremental search"""
1562
+ app = self.app
1563
+ self.search_bar.start(self.prev_filter)
1564
+ app.win.passthrough_mode = True
853
1565
 
854
- # Add header showing current theme
855
-
856
- app.win.add_header(f'COLOR THEME: {app.opts.theme:^18}', attr=cs.A_BOLD)
857
- app.win.add_header(' Press [t] to cycle themes, ESC to return', resume=True)
858
-
859
- # Color purpose labels
860
- color_labels = [
861
- (Theme.DANGER, 'DANGER', 'Destructive operations (wipe prompts)'),
862
- (Theme.SUCCESS, 'SUCCESS', 'Completed operations'),
863
- (Theme.OLD_SUCCESS, 'OLD_SUCCESS', 'Older Completed operations'),
864
- (Theme.WARNING, 'WARNING', 'Caution/stopped states'),
865
- (Theme.INFO, 'INFO', 'Informational states'),
866
- (Theme.EMPHASIS, 'EMPHASIS', 'Emphasized text'),
867
- (Theme.ERROR, 'ERROR', 'Errors'),
868
- (Theme.PROGRESS, 'PROGRESS', 'Progress indicators'),
869
- (Theme.HOTSWAP, 'HOTSWAP', 'Newly inserted devices'),
870
- ]
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
871
1626
 
872
- # Display color examples for current theme
873
- theme_info = Theme.THEMES[app.opts.theme]
874
- _ = theme_info.get('name', app.opts.theme)
875
-
876
- # Show color examples for this theme
877
- for color_id, label, description in color_labels:
878
- # Create line with colored block and description
879
- line = f'{label:12} ████████ {description}'
880
- attr = cs.color_pair(color_id)
881
- app.win.add_body(line, attr=attr)
882
-
883
- def spin_theme_ACTION(self):
884
- """ TBD """
885
- vals = Theme.list_all()
886
- value = Theme.get_current()
887
- idx = vals.index(value) if value in vals else -1
888
- value = vals[(idx+1) % len(vals)] # choose next
889
- Theme.set(value)
890
- self.app.opts.theme = value
1627
+ # Restore curses
1628
+ ConsoleWindow.start_curses()
1629
+ app.win.pick_pos = app.win.pick_pos # Force position refresh