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/DeviceWorker.py ADDED
@@ -0,0 +1,566 @@
1
+ """
2
+ DeviceWorker - Background thread for non-blocking device probing
3
+
4
+ Each device gets its own worker thread that handles potentially-blocking
5
+ operations (sysfs reads, hdparm, nvme commands) without freezing the UI.
6
+ """
7
+ import os
8
+ import time
9
+ import threading
10
+ import queue
11
+ from enum import Enum
12
+
13
+ from .DrivePreChecker import DrivePreChecker
14
+
15
+
16
+ class ProbeState(Enum):
17
+ """State of a probe operation"""
18
+ PENDING = "pending" # Not yet probed
19
+ PROBING = "probing" # Probe in progress
20
+ READY = "ready" # Value available
21
+ FAILED = "failed" # Probe failed/timed out
22
+ STALE = "stale" # Needs refresh
23
+
24
+
25
+ class DeviceWorker(threading.Thread):
26
+ """Background worker thread for a single device.
27
+
28
+ Handles potentially-blocking operations like:
29
+ - Reading sysfs attributes (can block if device in D state)
30
+ - Running hdparm/nvme commands for hardware capabilities
31
+
32
+ Main thread reads cached state via get_state() - never blocks.
33
+ """
34
+
35
+ def __init__(self, device_name, checker=None):
36
+ super().__init__(daemon=True, name=f"DeviceWorker-{device_name}")
37
+ self.device_name = device_name
38
+ self.checker = checker or DrivePreChecker()
39
+
40
+ # Thread control
41
+ self._running = True
42
+ self._work_queue = queue.Queue()
43
+
44
+ # Thread-safe state storage
45
+ self._lock = threading.Lock()
46
+ self._state = {
47
+ # Probe states
48
+ 'hw_caps_state': ProbeState.PENDING,
49
+ 'serial_state': ProbeState.PENDING,
50
+ 'model_state': ProbeState.PENDING,
51
+
52
+ # Cached values
53
+ 'hw_caps': '', # String with wipe mode names: "Ovwr, Block, Crypto*" (for choices, thread-safe)
54
+ 'hw_caps_summary': '', # Summary like "ϟCrypto" (for display, thread-safe)
55
+ 'hw_nopes': '', # String with issue names: "Frozen, Locked" (for display, thread-safe)
56
+ 'serial': '',
57
+ 'model': '',
58
+ 'vendor': '',
59
+
60
+ # Marker handling (formatted string returned to UI)
61
+ 'marker_formatted': '', # Empty string = no marker (or not wanted)
62
+ 'want_marker': False, # Main thread sets this; worker reads it
63
+
64
+ # Marker polling metadata (worker only)
65
+ 'last_marker_check_time': 0,
66
+ 'marker_found_time': 0, # When marker was first found (0 = not found)
67
+ 'marker_search_start_time': 0, # When we started looking for marker
68
+
69
+ # Metadata
70
+ 'last_error': None,
71
+ 'probe_count': 0,
72
+ 'is_usb': False, # True if device is on USB bus
73
+ 'is_rotational': False, # True if HDD (spinning disk)
74
+ }
75
+
76
+ # Device type detection
77
+ self._is_nvme = device_name.startswith('nv')
78
+ self._is_sata = device_name.startswith('sd')
79
+ self._is_hd = device_name.startswith('hd')
80
+
81
+ def run(self):
82
+ """Main worker loop - processes work queue and continuous marker polling."""
83
+ while self._running:
84
+ try:
85
+ # Wait for work with short timeout (allows responsive polling and clean shutdown)
86
+ task = self._work_queue.get(timeout=0.2)
87
+ if task == 'stop':
88
+ break
89
+ elif task == 'hw_caps':
90
+ self._probe_hw_caps()
91
+ elif task == 'serial':
92
+ self._probe_serial()
93
+ elif task == 'model':
94
+ self._probe_model()
95
+ elif task == 'refresh_all':
96
+ self._probe_serial()
97
+ self._probe_model()
98
+ elif task == 'check_marker':
99
+ # Immediate marker check (used after wipes for quick feedback)
100
+ self._probe_marker_once()
101
+ except queue.Empty:
102
+ # No task - check if we should probe marker based on polling strategy
103
+ self._check_marker_polling()
104
+
105
+ except Exception as e:
106
+ with self._lock:
107
+ self._state['last_error'] = str(e)
108
+
109
+ def stop(self):
110
+ """Signal worker to stop."""
111
+ self._running = False
112
+ self._work_queue.put('stop')
113
+
114
+ def request_hw_caps(self):
115
+ """Request hardware capabilities probe (non-blocking)."""
116
+ with self._lock:
117
+ if self._state['hw_caps_state'] == ProbeState.PENDING:
118
+ self._state['hw_caps_state'] = ProbeState.PROBING
119
+ self._work_queue.put('hw_caps')
120
+
121
+ def set_want_marker(self, want):
122
+ """Set whether we want to monitor for a marker (non-blocking).
123
+
124
+ Args:
125
+ want: Boolean - True to enable polling, False to clear and stop
126
+ """
127
+ with self._lock:
128
+ prev_want = self._state['want_marker']
129
+ self._state['want_marker'] = want
130
+
131
+ # If enabling monitoring, always reset search time (fresh start after wipe)
132
+ if want:
133
+ now = time.time()
134
+ self._state['marker_search_start_time'] = now
135
+ self._state['marker_found_time'] = 0
136
+ # If disabling monitoring, clear everything
137
+ elif prev_want:
138
+ self._state['marker_formatted'] = ''
139
+ self._state['marker_search_start_time'] = 0
140
+ self._state['marker_found_time'] = 0
141
+
142
+ def request_refresh(self):
143
+ """Request refresh of dynamic data (non-blocking)."""
144
+ self._work_queue.put('refresh_all')
145
+
146
+ def request_marker_check(self):
147
+ """Request immediate marker check (non-blocking).
148
+
149
+ Used to get quick feedback after wipes complete without waiting
150
+ for the polling interval.
151
+ """
152
+ self._work_queue.put('check_marker')
153
+
154
+ def get_state(self):
155
+ """Get current cached state (thread-safe, non-blocking).
156
+
157
+ Returns:
158
+ dict with all cached device info and probe states
159
+ """
160
+ with self._lock:
161
+ return self._state.copy()
162
+
163
+ def get_hw_caps(self):
164
+ """Get hardware capabilities if ready.
165
+
166
+ Returns:
167
+ tuple: (hw_caps, hw_caps_summary, hw_nopes, state, is_usb, is_rotational)
168
+ hw_caps: full sorted list for choices, hw_caps_summary: compact display
169
+ """
170
+ with self._lock:
171
+ return (
172
+ self._state['hw_caps'],
173
+ self._state['hw_caps_summary'],
174
+ self._state['hw_nopes'],
175
+ self._state['hw_caps_state'],
176
+ self._state['is_usb'],
177
+ self._state['is_rotational']
178
+ )
179
+
180
+ def get_marker_formatted(self):
181
+ """Get formatted marker string (ready to display).
182
+
183
+ Returns:
184
+ str: Formatted marker string, or empty string if no marker
185
+ """
186
+ with self._lock:
187
+ return self._state['marker_formatted']
188
+
189
+ def _probe_hw_caps(self):
190
+ """Probe hardware wipe capabilities (runs in worker thread)."""
191
+ with self._lock:
192
+ self._state['hw_caps_state'] = ProbeState.PROBING
193
+ self._state['probe_count'] += 1
194
+ # Detect USB (SATA-over-USB bridges can still support ATA passthrough)
195
+ self._state['is_usb'] = self._is_usb_device()
196
+ # Detect HDD vs SSD for ranking preferences
197
+ self._state['is_rotational'] = self._is_rotational_device()
198
+
199
+ try:
200
+ dev_path = f"/dev/{self.device_name}"
201
+ if self._is_nvme:
202
+ result = self.checker.check_nvme_drive(dev_path)
203
+ elif self._is_sata or self._is_hd:
204
+ result = self.checker.check_ata_drive(dev_path)
205
+ else:
206
+ # Unknown device type
207
+ with self._lock:
208
+ self._state['hw_caps_state'] = ProbeState.READY
209
+ return
210
+
211
+ # Store results as strings (immutable, no locking needed for reads)
212
+ # Sort modes by rank (worst to best) with '*' on the recommended (last) one
213
+ with self._lock:
214
+ is_rotational = self._state['is_rotational']
215
+ if result.modes:
216
+ # Use HDD rankings ONLY if rotational AND no crypto-capable firmware
217
+ # Note: 'Enhanced' is available on HDDs too (slow overwrite, not crypto)
218
+ # Only SCrypto/Crypto/FCrypto definitively indicate SSD with crypto
219
+ has_crypto_fw = any(m in result.modes for m in ('SCrypto', 'Crypto', 'FCrypto'))
220
+ use_hdd_ranking = is_rotational and not has_crypto_fw
221
+ sorted_modes = DrivePreChecker.sort_modes_by_rank(
222
+ result.modes.keys(), add_star=not use_hdd_ranking, is_rotational=use_hdd_ranking)
223
+ self._state['hw_caps'] = ', '.join(sorted_modes)
224
+ self._state['hw_caps_summary'] = DrivePreChecker.get_fw_caps_summary(result.modes.keys())
225
+ else:
226
+ self._state['hw_caps'] = ''
227
+ self._state['hw_caps_summary'] = ''
228
+ self._state['hw_nopes'] = ', '.join(result.issues.keys()) if result.issues else ''
229
+ self._state['hw_caps_state'] = ProbeState.READY
230
+
231
+ except Exception as e:
232
+ with self._lock:
233
+ self._state['hw_caps_state'] = ProbeState.FAILED
234
+ self._state['last_error'] = f"hw_caps: {e}"
235
+
236
+ def _probe_serial(self):
237
+ """Probe device serial number (runs in worker thread)."""
238
+ with self._lock:
239
+ self._state['serial_state'] = ProbeState.PROBING
240
+
241
+ try:
242
+ serial = self._read_sysfs_attr('device/serial')
243
+ if not serial:
244
+ # Try VPD page 80 (can be slow/blocking)
245
+ serial = self._read_vpd_serial()
246
+
247
+ with self._lock:
248
+ self._state['serial'] = serial
249
+ self._state['serial_state'] = ProbeState.READY
250
+
251
+ except Exception as e:
252
+ with self._lock:
253
+ self._state['serial_state'] = ProbeState.FAILED
254
+ self._state['last_error'] = f"serial: {e}"
255
+
256
+ def _probe_model(self):
257
+ """Probe device model (runs in worker thread)."""
258
+ with self._lock:
259
+ self._state['model_state'] = ProbeState.PROBING
260
+
261
+ try:
262
+ vendor = self._read_sysfs_attr('device/vendor')
263
+ model = self._read_sysfs_attr('device/model')
264
+
265
+ with self._lock:
266
+ self._state['vendor'] = vendor
267
+ self._state['model'] = model
268
+ self._state['model_state'] = ProbeState.READY
269
+
270
+ except Exception as e:
271
+ with self._lock:
272
+ self._state['model_state'] = ProbeState.FAILED
273
+ self._state['last_error'] = f"model: {e}"
274
+
275
+ def _check_marker_polling(self):
276
+ """Check if we should probe marker based on adaptive polling strategy.
277
+
278
+ Polling rates:
279
+ - 1s for first 12s when marker wanted but not found (initial search)
280
+ - 3s when marker has been found (keep it fresh)
281
+ - 30s when not found and initial search expired (wait for next wipe)
282
+ - Never poll when want_marker is False
283
+ """
284
+ with self._lock:
285
+ if not self._state['want_marker']:
286
+ return # Not monitoring
287
+
288
+ now = time.time()
289
+ last_check = self._state['last_marker_check_time']
290
+ search_start = self._state['marker_search_start_time']
291
+ found_time = self._state['marker_found_time']
292
+
293
+ # Determine polling interval based on state
294
+ if found_time > 0:
295
+ # Marker was found - refresh every 3s
296
+ interval = 3.0
297
+ elif now - search_start < 12.0:
298
+ # Still in initial search window (first 12s) - check every 1s
299
+ interval = 1.0
300
+ else:
301
+ # Initial search expired, no marker found - wait 30s before next check
302
+ interval = 30.0
303
+
304
+ # Check if enough time has passed
305
+ if now - last_check >= interval:
306
+ self._probe_marker_once()
307
+
308
+ def _probe_marker_once(self):
309
+ """Probe marker once and format the result as a string."""
310
+ try:
311
+ # Import here to avoid circular dependency
312
+ from .WipeJob import WipeJob
313
+ import datetime
314
+
315
+ marker = WipeJob.read_marker_buffer(self.device_name)
316
+
317
+ with self._lock:
318
+ now = time.time()
319
+ self._state['last_marker_check_time'] = now
320
+
321
+ if marker:
322
+ # Marker found - format it
323
+ self._state['marker_found_time'] = now
324
+ formatted = self._format_marker(marker)
325
+ self._state['marker_formatted'] = formatted
326
+ else:
327
+ # No marker found
328
+ self._state['marker_found_time'] = 0
329
+ self._state['marker_formatted'] = ''
330
+
331
+ except Exception as e:
332
+ with self._lock:
333
+ self._state['last_error'] = f"marker: {e}"
334
+
335
+ def _format_marker(self, marker):
336
+ """Format marker object as a display string.
337
+
338
+ Returns:
339
+ str: Formatted marker like "✓ W 100% Zero 2026/01/23 14:30"
340
+ """
341
+ import datetime
342
+
343
+ try:
344
+ now = int(round(time.time()))
345
+ if marker.size_bytes == 0 or marker.unixtime >= now:
346
+ return '' # Invalid marker
347
+
348
+ pct = min(100, int(round((marker.scrubbed_bytes / marker.size_bytes) * 100)))
349
+ state = 'W' if pct >= 100 else 's'
350
+ dt = datetime.datetime.fromtimestamp(marker.unixtime)
351
+
352
+ # Build prefix with verify status
353
+ verify_prefix = ''
354
+ verify_status = getattr(marker, 'verify_status', None)
355
+ if verify_status == 'pass':
356
+ verify_prefix = '✓ '
357
+ elif verify_status == 'fail':
358
+ verify_prefix = '✗ '
359
+
360
+ # Build suffix with error info
361
+ error_suffix = ''
362
+ abort_reason = getattr(marker, 'abort_reason', None)
363
+ if abort_reason:
364
+ error_suffix = f' Err[{abort_reason}]'
365
+
366
+ return f'{verify_prefix}{state} {pct}% {marker.mode} {dt.strftime("%Y/%m/%d %H:%M")}{error_suffix}'
367
+ except Exception:
368
+ return ''
369
+
370
+ def _read_sysfs_attr(self, attr_path):
371
+ """Read a sysfs attribute for this device.
372
+
373
+ Note: This CAN block if device is in D state.
374
+ That's why it runs in the worker thread, not main thread.
375
+ """
376
+ path = f"/sys/class/block/{self.device_name}/{attr_path}"
377
+ try:
378
+ with open(path, 'r') as f:
379
+ # Sanitize: some USB bridges return strings with embedded nulls
380
+ return f.read().strip().replace('\x00', '')
381
+ except (FileNotFoundError, IOError, OSError):
382
+ return ''
383
+
384
+ def _read_vpd_serial(self):
385
+ """Read serial from VPD page 80 (SCSI).
386
+
387
+ WARNING: This can block indefinitely on unresponsive devices.
388
+ """
389
+ path = f"/sys/class/block/{self.device_name}/device/vpd_pg80"
390
+ try:
391
+ with open(path, 'rb') as f:
392
+ data = f.read()
393
+ if len(data) > 4:
394
+ # Sanitize: remove nulls that some USB bridges include
395
+ return data[4:].decode('ascii', errors='ignore').strip().replace('\x00', '')
396
+ except (FileNotFoundError, IOError, OSError):
397
+ pass
398
+ return ''
399
+
400
+ def _is_usb_device(self):
401
+ """Check if device is connected via USB bus."""
402
+ try:
403
+ sysfs_path = f'/sys/class/block/{self.device_name}'
404
+ real_path = os.path.realpath(sysfs_path).lower()
405
+ return '/usb' in real_path
406
+ except (OSError, IOError):
407
+ return False
408
+
409
+ def _is_rotational_device(self):
410
+ """Check if device is rotational (HDD) vs solid-state (SSD).
411
+
412
+ Reads /sys/block/<device>/queue/rotational:
413
+ - 1 = HDD (spinning disk)
414
+ - 0 = SSD (solid-state)
415
+
416
+ Returns:
417
+ bool: True if HDD (rotational), False if SSD or unknown
418
+ """
419
+ try:
420
+ path = f'/sys/block/{self.device_name}/queue/rotational'
421
+ with open(path, 'r', encoding='utf-8') as f:
422
+ return f.read().strip() == '1'
423
+ except (FileNotFoundError, PermissionError, OSError):
424
+ return False
425
+
426
+
427
+ class DeviceWorkerManager:
428
+ """Manages DeviceWorker instances for all devices.
429
+
430
+ Creates workers when devices appear, stops them when devices disappear.
431
+ Provides unified interface for main thread to access device state.
432
+ """
433
+
434
+ def __init__(self, checker=None):
435
+ self.checker = checker or DrivePreChecker()
436
+ self._workers = {} # device_name -> DeviceWorker
437
+ self._lock = threading.Lock()
438
+
439
+ def update_devices(self, device_names):
440
+ """Update worker set based on current device list.
441
+
442
+ Args:
443
+ device_names: set or list of current device names (e.g., {'sda', 'nvme0n1'})
444
+ """
445
+ device_names = set(device_names)
446
+
447
+ with self._lock:
448
+ current = set(self._workers.keys())
449
+
450
+ # Stop workers for removed devices
451
+ for name in current - device_names:
452
+ worker = self._workers.pop(name)
453
+ worker.stop()
454
+
455
+ # Create workers for new devices
456
+ for name in device_names - current:
457
+ # Create workers for all devices (disks and partitions)
458
+ # Partitions need workers too for marker reading
459
+ worker = DeviceWorker(name, self.checker)
460
+ worker.start()
461
+ self._workers[name] = worker
462
+
463
+ def request_hw_caps(self, device_name):
464
+ """Request hardware capabilities probe for a device."""
465
+ with self._lock:
466
+ worker = self._workers.get(device_name)
467
+ if worker:
468
+ worker.request_hw_caps()
469
+
470
+ def set_want_marker(self, device_name, want):
471
+ """Set whether we want to monitor for a marker on a device.
472
+
473
+ Args:
474
+ device_name: Device to monitor
475
+ want: Boolean - True to enable polling, False to disable
476
+ """
477
+ with self._lock:
478
+ worker = self._workers.get(device_name)
479
+ if worker:
480
+ worker.set_want_marker(want)
481
+
482
+ def request_marker_check(self, device_name):
483
+ """Request immediate marker check for a device (non-blocking).
484
+
485
+ Used to get quick feedback after wipes complete without waiting
486
+ for the polling interval.
487
+ """
488
+ with self._lock:
489
+ worker = self._workers.get(device_name)
490
+ if worker:
491
+ worker.request_marker_check()
492
+
493
+ def get_marker_formatted(self, device_name):
494
+ """Get formatted marker string for a device (ready to display).
495
+
496
+ Returns:
497
+ str: Formatted marker string, or empty string if no marker
498
+ """
499
+ with self._lock:
500
+ worker = self._workers.get(device_name)
501
+ if worker:
502
+ return worker.get_marker_formatted()
503
+ return ''
504
+
505
+ def get_hw_caps(self, device_name):
506
+ """Get hardware capabilities for a device.
507
+
508
+ Returns:
509
+ tuple: (hw_caps, hw_caps_summary, hw_nopes, state, is_usb, is_rotational)
510
+ """
511
+ with self._lock:
512
+ worker = self._workers.get(device_name)
513
+ if worker:
514
+ return worker.get_hw_caps()
515
+ return ('', '', '', ProbeState.PENDING, False, False)
516
+
517
+ def get_state(self, device_name):
518
+ """Get full cached state for a device."""
519
+ with self._lock:
520
+ worker = self._workers.get(device_name)
521
+ if worker:
522
+ return worker.get_state()
523
+ return None
524
+
525
+ def stop_all(self):
526
+ """Stop all workers (call on shutdown)."""
527
+ with self._lock:
528
+ for worker in self._workers.values():
529
+ worker.stop()
530
+ # Wait for all to finish
531
+ for worker in self._workers.values():
532
+ worker.join(timeout=2.0)
533
+ self._workers.clear()
534
+
535
+ def has_updates(self, cached_state):
536
+ """Check if any markers or hw_caps have changed since cached_state.
537
+
538
+ Args:
539
+ cached_state: dict mapping device_name -> {'marker': str, 'hw_caps_state': ProbeState}
540
+
541
+ Returns:
542
+ bool: True if any markers or hw_caps differ from cached_state
543
+ """
544
+ with self._lock:
545
+ for device_name, worker in self._workers.items():
546
+ cached = cached_state.get(device_name, {})
547
+ current_state = worker.get_state()
548
+ # Check if marker changed
549
+ if current_state.get('marker_formatted') != cached.get('marker'):
550
+ return True
551
+ # Check if hw_caps changed (from PENDING to READY)
552
+ cached_hw_state = cached.get('hw_caps_state', ProbeState.PENDING)
553
+ if current_state.get('hw_caps_state') != cached_hw_state:
554
+ return True
555
+ return False
556
+
557
+ @staticmethod
558
+ def _is_whole_disk(name):
559
+ """Check if device name is a whole disk (not a partition)."""
560
+ # NVMe: nvme0n1 is disk, nvme0n1p1 is partition
561
+ if name.startswith('nvme'):
562
+ return 'p' not in name.split('n')[-1]
563
+ # SATA/IDE: sda is disk, sda1 is partition (partitions end with digits)
564
+ if name.startswith(('sd', 'hd')):
565
+ return not name[-1].isdigit()
566
+ return False