dwipe 2.0.2__py3-none-any.whl → 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dwipe/DeviceChangeMonitor.py +244 -0
- dwipe/DeviceInfo.py +589 -194
- dwipe/DeviceWorker.py +566 -0
- dwipe/DiskWipe.py +558 -134
- dwipe/DrivePreChecker.py +161 -48
- dwipe/FirmwareWipeTask.py +627 -132
- dwipe/NvmeTool.py +225 -0
- dwipe/PersistentState.py +20 -9
- dwipe/SataTool.py +499 -0
- dwipe/StructuredLogger.py +4 -3
- dwipe/Tunables.py +62 -0
- dwipe/Utils.py +192 -5
- dwipe/VerifyTask.py +4 -2
- dwipe/WipeJob.py +25 -13
- dwipe/WipeTask.py +4 -2
- dwipe/WriteTask.py +1 -1
- dwipe/main.py +28 -8
- {dwipe-2.0.2.dist-info → dwipe-3.0.0.dist-info}/METADATA +219 -99
- dwipe-3.0.0.dist-info/RECORD +24 -0
- dwipe/LsblkMonitor.py +0 -124
- dwipe/ToolManager.py +0 -618
- dwipe-2.0.2.dist-info/RECORD +0 -21
- {dwipe-2.0.2.dist-info → dwipe-3.0.0.dist-info}/WHEEL +0 -0
- {dwipe-2.0.2.dist-info → dwipe-3.0.0.dist-info}/entry_points.txt +0 -0
- {dwipe-2.0.2.dist-info → dwipe-3.0.0.dist-info}/licenses/LICENSE +0 -0
dwipe/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
|