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