dwipe 2.0.1__py3-none-any.whl → 2.0.2__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.
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env python3
2
+ import subprocess
3
+ import os
4
+ import json
5
+ from typing import Dict, List, Optional
6
+ from dataclasses import dataclass, field
7
+
8
+ @dataclass
9
+ class PreCheckResult:
10
+ # Key = Short Code (Frozen, Locked), Value = Long Description
11
+ issues: Dict[str, str] = field(default_factory=dict)
12
+ modes: Dict[str, str] = field(default_factory=dict)
13
+
14
+ class DrivePreChecker:
15
+ def __init__(self, timeout: int = 10):
16
+ self.timeout = timeout
17
+
18
+ def check_nvme_drive(self, device: str) -> PreCheckResult:
19
+ result = PreCheckResult()
20
+ try:
21
+ id_ctrl = subprocess.run(
22
+ ['nvme', 'id-ctrl', device, '-o', 'json'],
23
+ check=False, capture_output=True, text=True, timeout=self.timeout
24
+ )
25
+
26
+ if id_ctrl.returncode != 0:
27
+ result.issues['Unresponsive'] = "NVMe controller did not respond to id-ctrl"
28
+ return result
29
+
30
+ data = json.loads(id_ctrl.stdout)
31
+
32
+ # 1. Sanitize Support
33
+ sanicap = data.get('sanicap', 0)
34
+ if sanicap > 0:
35
+ if sanicap & 0x04: result.modes['CryptoNv'] = 'sanitize --action=0x04'
36
+ if sanicap & 0x02: result.modes['BlockNv'] = 'sanitize --action=0x02'
37
+ if sanicap & 0x08: result.modes['OvwrNv'] = 'sanitize --action=0x03'
38
+
39
+ # 2. Format Support (Legacy)
40
+ if 'Format NVM' in id_ctrl.stdout:
41
+ fna = data.get('fna', 0)
42
+ if (fna >> 2) & 0x1:
43
+ result.modes['FmtCryptoNv'] = 'format --ses=2'
44
+ result.modes['FmtEraseNv'] = 'format --ses=1'
45
+
46
+ if not result.modes:
47
+ result.issues['Unsupported'] = "Drive lacks Sanitize or Format NVM capabilities"
48
+
49
+ except Exception as e:
50
+ result.issues['Error'] = f"NVMe Probe Exception: {str(e)}"
51
+
52
+ return result
53
+
54
+ def check_ata_drive(self, device: str) -> PreCheckResult:
55
+ result = PreCheckResult()
56
+ try:
57
+ info = subprocess.run(
58
+ ['hdparm', '-I', device],
59
+ check=False, capture_output=True, text=True, timeout=self.timeout
60
+ )
61
+
62
+ if info.returncode != 0:
63
+ result.issues['Unresponsive'] = "Drive did not respond to hdparm"
64
+ return result
65
+
66
+ out = info.stdout.lower()
67
+
68
+ # 1. Hardware Support Check
69
+ if "security erase unit" not in out:
70
+ result.issues['Unsupported'] = "Drive does not support ATA Security Erase"
71
+ return result
72
+
73
+ # 2. Frozen Check
74
+ if "frozen" in out and "not frozen" not in out:
75
+ result.issues['Frozen'] = "Drive is FROZEN (BIOS/OS lock). Cycle power or Suspend/Resume."
76
+
77
+ # 3. Security Enabled (Password set)
78
+ if "enabled" in out and "not enabled" not in out:
79
+ result.issues['Locked'] = "Security is ENABLED (Drive is currently password locked)"
80
+
81
+ # 4. Populate Modes only if no fatal issues
82
+ if not result.issues:
83
+ if "enhanced erase" in out:
84
+ result.modes['EnhancedHd'] = '--user-master u --security-erase-enhanced NULL'
85
+ result.modes['EraseHd'] = '--user-master u --security-erase NULL'
86
+
87
+ except Exception as e:
88
+ result.issues['Error'] = f"ATA Probe Exception: {str(e)}"
89
+
90
+ return result
@@ -0,0 +1,370 @@
1
+ """
2
+ FirmwareWipeTask - Firmware-based secure erase operations
3
+
4
+ Includes:
5
+ - FirmwareWipeTask: Abstract base class for firmware wipes
6
+ - NvmeWipeTask: NVMe secure erase using nvme-cli
7
+ - SataWipeTask: SATA/ATA secure erase using hdparm
8
+ """
9
+ # pylint: disable=broad-exception-raised,broad-exception-caught
10
+ import os
11
+ import json
12
+ import time
13
+ import subprocess
14
+ import traceback
15
+ # from types import SimpleNamespace
16
+
17
+ from .WipeTask import WipeTask
18
+ from .Utils import Utils
19
+
20
+
21
+ class FirmwareWipeTask(WipeTask):
22
+ """Abstract base class for firmware-based wipe operations
23
+
24
+ Firmware wipes execute in the drive's controller, not via CPU writes.
25
+ The host just sends a command and monitors for completion.
26
+
27
+ Progress reporting is estimated since most firmware doesn't report
28
+ real-time progress. NVMe sanitize can optionally poll for actual progress.
29
+
30
+ Subclasses must implement:
31
+ - _build_command(): Return list of command args
32
+ - _check_completion(): Check if wipe is complete
33
+ - _estimate_duration(): Estimate total time in seconds
34
+ """
35
+
36
+ def __init__(self, device_path, total_size, opts, command_args, wipe_name):
37
+ """Initialize firmware wipe task
38
+
39
+ Args:
40
+ device_path: Path to device (e.g., '/dev/sda', '/dev/nvme0n1')
41
+ total_size: Total size in bytes
42
+ opts: Options namespace
43
+ command_args: Command args from hw_caps (e.g., 'sanitize --action=0x04')
44
+ wipe_name: Human-readable name (e.g., 'Sanitize-Crypto')
45
+ """
46
+ super().__init__(device_path, total_size, opts)
47
+
48
+ self.command_args = command_args
49
+ self.wipe_name = wipe_name
50
+ self.process = None
51
+ self.finish_mono = None
52
+
53
+ # Estimated duration for progress reporting
54
+ self.estimated_duration = self._estimate_duration()
55
+
56
+ def _estimate_duration(self):
57
+ """Estimate total duration in seconds (override in subclasses)
58
+
59
+ Returns:
60
+ int: Estimated seconds for completion
61
+ """
62
+ return 60 # Default 1 minute
63
+
64
+ def get_display_name(self):
65
+ """Get display name for firmware wipe"""
66
+ return self.wipe_name
67
+
68
+ def _build_command(self):
69
+ """Build command list for subprocess (must be implemented by subclasses)
70
+
71
+ Returns:
72
+ list: Command args like ['nvme', 'sanitize', ...]
73
+ """
74
+ raise NotImplementedError("Subclasses must implement _build_command()")
75
+
76
+ def _check_completion(self):
77
+ """Check if wipe completed successfully (can be overridden)
78
+
79
+ Returns:
80
+ bool or None: True if done, False if failed, None if still running
81
+ """
82
+ if self.process and self.process.poll() is not None:
83
+ return self.process.returncode == 0
84
+ return None
85
+
86
+ def _write_marker(self):
87
+ """Write completion marker after firmware wipe
88
+
89
+ Firmware wipes erase the entire disk including any existing markers.
90
+ We need to write a new marker indicating the wipe is complete.
91
+ """
92
+ try:
93
+ # Force OS to re-read partition table (now empty)
94
+ subprocess.run(['blockdev', '--rereadpt', self.device_path],
95
+ capture_output=True, timeout=5, check=False)
96
+ time.sleep(1) # Let kernel settle
97
+
98
+ # Prepare marker data
99
+ data = {
100
+ "unixtime": int(time.time()),
101
+ "scrubbed_bytes": self.total_size,
102
+ "size_bytes": self.total_size,
103
+ "passes": 1,
104
+ "mode": self.wipe_name, # e.g., 'Sanitize-Crypto'
105
+ "firmware_wipe": True
106
+ }
107
+ json_data = json.dumps(data).encode('utf-8')
108
+
109
+ # Build marker buffer (16KB)
110
+ buffer = bytearray(WipeTask.MARKER_SIZE)
111
+ buffer[:WipeTask.STATE_OFFSET] = b'\x00' * WipeTask.STATE_OFFSET
112
+ buffer[WipeTask.STATE_OFFSET:WipeTask.STATE_OFFSET + len(json_data)] = json_data
113
+ remaining = WipeTask.MARKER_SIZE - (WipeTask.STATE_OFFSET + len(json_data))
114
+ buffer[WipeTask.STATE_OFFSET + len(json_data):] = b'\x00' * remaining
115
+
116
+ # Write marker to beginning of device
117
+ with open(self.device_path, 'wb') as f:
118
+ f.write(buffer)
119
+ f.flush()
120
+ os.fsync(f.fileno())
121
+
122
+ except Exception as e:
123
+ # Don't fail the whole job if marker write fails
124
+ self.exception = f"Marker write warning: {e}"
125
+
126
+ def run_task(self):
127
+ """Execute firmware wipe operation (blocking, runs in thread)"""
128
+ try:
129
+ # Build command
130
+ cmd = self._build_command()
131
+
132
+ # Start subprocess (non-blocking)
133
+ self.process = subprocess.Popen(
134
+ cmd,
135
+ stdout=subprocess.PIPE,
136
+ stderr=subprocess.PIPE,
137
+ text=True
138
+ )
139
+
140
+ # Monitor progress with polling loop
141
+ check_interval = 2 # Check every 2 seconds
142
+
143
+ while not self.do_abort:
144
+ # Check if process completed
145
+ completion_status = self._check_completion()
146
+
147
+ if completion_status is True:
148
+ # Success!
149
+ self.total_written = self.total_size
150
+ self.finish_mono = time.monotonic()
151
+
152
+ # Write marker after successful firmware wipe
153
+ self._write_marker()
154
+ break
155
+
156
+ elif completion_status is False:
157
+ # Failed
158
+ stderr = self.process.stderr.read() if self.process.stderr else ""
159
+ self.exception = f"Firmware wipe failed: {stderr}"
160
+ break
161
+
162
+ # Still running - update estimated progress
163
+ elapsed = time.monotonic() - self.start_mono
164
+ progress_pct = min(1.0, elapsed / self.estimated_duration)
165
+ self.total_written = int(self.total_size * progress_pct)
166
+
167
+ time.sleep(check_interval)
168
+
169
+ # Handle abort
170
+ if self.do_abort and self.process:
171
+ self.process.terminate()
172
+ time.sleep(0.5)
173
+ if self.process.poll() is None:
174
+ self.process.kill()
175
+
176
+ except Exception:
177
+ self.exception = traceback.format_exc()
178
+ finally:
179
+ self.done = True
180
+
181
+ def get_status(self):
182
+ """Get current progress status (thread-safe)
183
+
184
+ Returns:
185
+ tuple: (elapsed_str, pct_str, rate_str, eta_str)
186
+ """
187
+ mono = time.monotonic()
188
+ elapsed_time = mono - self.start_mono
189
+
190
+ # Calculate percentage based on estimated progress
191
+ pct = (self.total_written / self.total_size) * 100 if self.total_size > 0 else 0
192
+ pct = min(pct, 100)
193
+ pct_str = f'{int(round(pct))}%'
194
+
195
+ if self.do_abort:
196
+ pct_str = 'STOP'
197
+
198
+ # Show "FW" to indicate firmware operation
199
+ rate_str = 'FW'
200
+
201
+ # Calculate ETA based on estimated duration
202
+ if pct < 100:
203
+ remaining = self.estimated_duration - elapsed_time
204
+ eta_str = Utils.ago_str(max(0, int(remaining)))
205
+ else:
206
+ eta_str = '0'
207
+
208
+ elapsed_str = Utils.ago_str(int(round(elapsed_time)))
209
+
210
+ return elapsed_str, pct_str, rate_str, eta_str
211
+
212
+ def get_summary_dict(self):
213
+ """Generate summary dictionary for structured logging
214
+
215
+ Returns:
216
+ dict: Summary with step details
217
+ """
218
+ mono = time.monotonic()
219
+ elapsed = mono - self.start_mono
220
+
221
+ return {
222
+ "step": f"firmware {self.wipe_name} {self.device_path}",
223
+ "elapsed": Utils.ago_str(int(elapsed)),
224
+ "rate": "Firmware",
225
+ "command": ' '.join(self._build_command()),
226
+ "bytes_written": self.total_written,
227
+ "bytes_total": self.total_size,
228
+ "result": "completed" if self.total_written == self.total_size else "partial"
229
+ }
230
+
231
+
232
+ class NvmeWipeTask(FirmwareWipeTask):
233
+ """NVMe firmware wipe using nvme-cli
234
+
235
+ Supports various sanitize and format operations:
236
+ - Sanitize: Crypto Erase, Block Erase, Overwrite
237
+ - Format: Crypto Erase, User Data Erase
238
+
239
+ Example command_args:
240
+ - 'sanitize --action=0x04' (Crypto Erase)
241
+ - 'format --ses=2' (Format with Crypto Erase)
242
+ """
243
+
244
+ def _estimate_duration(self):
245
+ """Estimate NVMe wipe duration
246
+
247
+ Most NVMe sanitize/format operations complete in seconds.
248
+ Crypto erase: 2-10 seconds
249
+ Block erase: 10-30 seconds
250
+ Overwrite: 30-120 seconds
251
+ """
252
+ if 'sanitize' in self.command_args:
253
+ if 'crypto' in self.command_args or '0x04' in self.command_args:
254
+ return 10 # Crypto erase is very fast
255
+ elif 'block' in self.command_args or '0x02' in self.command_args:
256
+ return 30
257
+ else: # Overwrite
258
+ return 120
259
+ else: # Format
260
+ return 30
261
+
262
+ def _build_command(self):
263
+ """Build nvme command
264
+
265
+ Returns:
266
+ list: ['nvme', 'sanitize', '--action=0x04', '/dev/nvme0n1']
267
+ """
268
+ # Parse command_args: 'sanitize --action=0x04'
269
+ parts = self.command_args.split()
270
+ cmd = ['nvme'] + parts + [self.device_path]
271
+ return cmd
272
+
273
+ def _check_completion(self):
274
+ """Check NVMe wipe completion
275
+
276
+ Can optionally poll 'nvme sanitize-log' for actual progress.
277
+ For now, just check if process exited.
278
+ """
279
+ if self.process and self.process.poll() is not None:
280
+ return self.process.returncode == 0
281
+ return None
282
+
283
+ # TODO: Implement real-time progress polling via 'nvme sanitize-log'
284
+ # def _get_sanitize_progress(self):
285
+ # """Query actual sanitize progress from device"""
286
+ # try:
287
+ # result = subprocess.run(
288
+ # ['nvme', 'sanitize-log', self.device_path, '-o', 'json'],
289
+ # capture_output=True, text=True, timeout=5
290
+ # )
291
+ # if result.returncode == 0:
292
+ # data = json.loads(result.stdout)
293
+ # # Parse progress from data
294
+ # return progress_pct
295
+ # except:
296
+ # pass
297
+ # return None
298
+
299
+
300
+ class SataWipeTask(FirmwareWipeTask):
301
+ """SATA/ATA firmware wipe using hdparm
302
+
303
+ Uses ATA Security Erase command:
304
+ - Normal Erase: Writes zeros to all sectors (slow)
305
+ - Enhanced Erase: Cryptographic erase or vendor-specific (fast)
306
+
307
+ Example command_args:
308
+ - '--user-master u --security-erase NULL'
309
+ - '--user-master u --security-erase-enhanced NULL'
310
+
311
+ Note: Requires setting a temporary password before erase.
312
+ """
313
+
314
+ def _estimate_duration(self):
315
+ """Estimate SATA wipe duration
316
+
317
+ Enhanced erase: 2-10 minutes (varies by vendor)
318
+ Normal erase: ~1 hour per TB
319
+ """
320
+ if 'enhanced' in self.command_args:
321
+ return 600 # 10 minutes for enhanced
322
+ else:
323
+ # Estimate based on size: 1 hour per TB
324
+ size_tb = self.total_size / (1024**4)
325
+ hours = max(0.5, size_tb)
326
+ return int(hours * 3600)
327
+
328
+ def _build_command(self):
329
+ """Build hdparm command
330
+
331
+ For security erase, we need to:
332
+ 1. Set password: hdparm --user-master u --security-set-pass NULL /dev/sda
333
+ 2. Erase: hdparm --user-master u --security-erase NULL /dev/sda
334
+
335
+ We'll just build the erase command - password setting happens in run_task
336
+ """
337
+ # Parse: '--user-master u --security-erase-enhanced NULL'
338
+ parts = self.command_args.split()
339
+ cmd = ['hdparm'] + parts + [self.device_path]
340
+ return cmd
341
+
342
+ def _set_ata_password(self):
343
+ """Set temporary ATA password before erase
344
+
345
+ Returns:
346
+ bool: True if successful
347
+ """
348
+ try:
349
+ cmd = ['hdparm', '--user-master', 'u', '--security-set-pass', 'NULL', self.device_path]
350
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
351
+ return result.returncode == 0
352
+ except Exception as e:
353
+ self.exception = f"Failed to set ATA password: {e}"
354
+ return False
355
+
356
+ def run_task(self):
357
+ """Execute SATA firmware wipe (overrides base to add password step)"""
358
+ try:
359
+ # Step 1: Set temporary password
360
+ if not self._set_ata_password():
361
+ self.exception = "Failed to set ATA security password"
362
+ self.done = True
363
+ return
364
+
365
+ # Step 2: Execute erase command (base class handles this)
366
+ super().run_task()
367
+
368
+ except Exception:
369
+ self.exception = traceback.format_exc()
370
+ self.done = True
dwipe/LsblkMonitor.py ADDED
@@ -0,0 +1,124 @@
1
+ """
2
+ LsblkMonitor - Background thread for monitoring block device changes
3
+ """
4
+ import os
5
+ import threading
6
+ import subprocess
7
+
8
+
9
+ class LsblkMonitor:
10
+ """Background monitor that checks for block device changes and runs lsblk"""
11
+
12
+ def __init__(self, check_interval=0.2):
13
+ """
14
+ Initialize the lsblk monitor.
15
+
16
+ Args:
17
+ check_interval: How often to check for changes (seconds)
18
+ """
19
+ self.check_interval = check_interval
20
+ self.lsblk_str = ""
21
+ self._lock = threading.Lock()
22
+ self._thread = None
23
+ self._stop_event = threading.Event()
24
+ self.last_fingerprint = None
25
+
26
+ def start(self):
27
+ """Start the background monitoring thread"""
28
+ if self._thread is not None and self._thread.is_alive():
29
+ return # Already running
30
+
31
+ self._stop_event.clear()
32
+ self._thread = threading.Thread(target=self._monitor_loop, daemon=True)
33
+ self._thread.start()
34
+
35
+ def stop(self):
36
+ """Stop the background monitoring thread"""
37
+ self._stop_event.set()
38
+ if self._thread is not None:
39
+ self._thread.join(timeout=1.0)
40
+
41
+ def check_for_changes(self):
42
+ """
43
+ Check if block devices or partitions have changed.
44
+
45
+ Returns:
46
+ True if changes detected, False otherwise
47
+ """
48
+ try:
49
+ # 1. Quickest check: Does the list of block devices match?
50
+ # This catches "Forget" (DEL) and "Scan" (!) events immediately.
51
+ current_devs = os.listdir('/sys/class/block')
52
+
53
+ # 2. Secondary check: Do the partition sizes/counts match?
54
+ with open('/proc/partitions', 'r', encoding='utf-8') as f:
55
+ current_parts = f.read()
56
+
57
+ # Create a combined "Fingerprint"
58
+ fingerprint = f"{len(current_devs)}|{current_parts}"
59
+
60
+ if fingerprint != self.last_fingerprint:
61
+ self.last_fingerprint = fingerprint
62
+ return True
63
+
64
+ except Exception: # pylint: disable=broad-exception-caught
65
+ # If we can't read /sys or /proc, default to True
66
+ # so we don't get stuck with a blank screen.
67
+ return True
68
+ return False
69
+
70
+ def _run_lsblk(self):
71
+ """
72
+ Run lsblk and capture output in JSON format matching DeviceInfo.parse_lsblk requirements.
73
+
74
+ Returns:
75
+ JSON output string from lsblk command
76
+ """
77
+ try:
78
+ result = subprocess.run(
79
+ ['lsblk', '-J', '--bytes', '-o',
80
+ 'NAME,MAJ:MIN,FSTYPE,TYPE,LABEL,PARTLABEL,FSUSE%,SIZE,MOUNTPOINTS,UUID,PARTUUID,SERIAL'],
81
+ capture_output=True,
82
+ text=True,
83
+ timeout=5.0,
84
+ check=False
85
+ )
86
+ return result.stdout
87
+ except Exception: # pylint: disable=broad-exception-caught
88
+ return "" # Return empty string on error
89
+
90
+ def _monitor_loop(self):
91
+ """Background thread loop that monitors for changes"""
92
+ while not self._stop_event.is_set():
93
+ if self.check_for_changes():
94
+ # Changes detected, run lsblk
95
+ lsblk_output = self._run_lsblk()
96
+
97
+ # Store the result in a thread-safe manner
98
+ with self._lock:
99
+ self.lsblk_str = lsblk_output
100
+
101
+ # Sleep for the check interval
102
+ self._stop_event.wait(self.check_interval)
103
+
104
+ def get_and_clear(self):
105
+ """
106
+ Get the latest lsblk output and clear it.
107
+
108
+ Returns:
109
+ String containing lsblk output, or empty string if no new data
110
+ """
111
+ with self._lock:
112
+ result = self.lsblk_str
113
+ self.lsblk_str = ""
114
+ return result
115
+
116
+ def peek(self):
117
+ """
118
+ Get the latest lsblk output without clearing it.
119
+
120
+ Returns:
121
+ String containing lsblk output, or empty string if no new data
122
+ """
123
+ with self._lock:
124
+ return self.lsblk_str
dwipe/PersistentState.py CHANGED
@@ -9,7 +9,7 @@ from .Utils import Utils
9
9
 
10
10
 
11
11
  class PersistentState:
12
- """Manages persistent state for dwipe preferences and device locks"""
12
+ """Manages persistent state for dwipe preferences and device blocks"""
13
13
 
14
14
  def __init__(self, config_path=None):
15
15
  """Initialize persistent state
@@ -24,7 +24,7 @@ class PersistentState:
24
24
  self.config_path = Path(config_path)
25
25
  self.state = {
26
26
  'theme': 'default',
27
- 'wipe_mode': 'Zero', # 'Rand' or 'Zero' or +V
27
+ 'wipe_mode': '+V', # '+V' (verify) or '-V' (no verify)
28
28
  'passes': 1, # 1, 2, or 4 wipe pass
29
29
  'confirmation': 'YES', # 'Y', 'y', 'YES', 'yes', 'device'
30
30
  'verify_pct': 2, # 0, 2, 5, 10, 25, 50, 100
@@ -32,7 +32,7 @@ class PersistentState:
32
32
  'slowdown_stop': 16,
33
33
  'stall_timeout': 60,
34
34
  'port_serial': False,
35
- 'devices': {} # device_id -> {locked, last_seen, last_name, size_bytes}
35
+ 'devices': {} # device_id -> {blocked, last_seen, last_name, size_bytes}
36
36
  }
37
37
  self.dirty = False
38
38
  self.max_devices = 400
@@ -63,6 +63,18 @@ class PersistentState:
63
63
  with open(self.config_path, 'r', encoding='utf-8') as f:
64
64
  loaded = json.load(f)
65
65
  self.state.update(loaded)
66
+
67
+ # Migrate old wipe_mode values to new format
68
+ old_wipe_mode = self.state.get('wipe_mode', '+V')
69
+ if old_wipe_mode not in ['+V', '-V']:
70
+ # Old format: 'Zero', 'Zero+V', 'Rand', 'Rand+V'
71
+ # Convert to new format: '+V' or '-V'
72
+ if '+V' in str(old_wipe_mode):
73
+ self.state['wipe_mode'] = '+V'
74
+ else:
75
+ self.state['wipe_mode'] = '-V'
76
+ self.dirty = True # Save the migration
77
+
66
78
  except (json.JSONDecodeError, IOError) as e:
67
79
  print(f'Warning: Could not load state from {self.config_path}: {e}')
68
80
 
@@ -134,24 +146,28 @@ class PersistentState:
134
146
  return f'fallback:{fallback_id}'
135
147
 
136
148
  def get_device_locked(self, partition):
137
- """Check if a device is locked
149
+ """Check if a device is blocked (backward compatible with 'locked')
138
150
 
139
151
  Args:
140
152
  partition: SimpleNamespace with device info
141
153
 
142
154
  Returns:
143
- bool: True if device is locked
155
+ bool: True if device is blocked
144
156
  """
145
157
  device_id = self.make_device_id(partition)
146
158
  device_state = self.state['devices'].get(device_id, {})
159
+
160
+ # Check new 'blocked' field first, fall back to old 'locked' field for backward compatibility
161
+ if 'blocked' in device_state:
162
+ return device_state['blocked']
147
163
  return device_state.get('locked', False)
148
164
 
149
165
  def set_device_locked(self, partition, locked):
150
- """Set device lock state
166
+ """Set device block state
151
167
 
152
168
  Args:
153
169
  partition: SimpleNamespace with device info
154
- locked: bool, True to lock device
170
+ locked: bool, True to block device (parameter name kept for API compatibility)
155
171
  """
156
172
  device_id = self.make_device_id(partition)
157
173
  now = int(time.time())
@@ -160,7 +176,9 @@ class PersistentState:
160
176
  self.state['devices'][device_id] = {}
161
177
 
162
178
  device_state = self.state['devices'][device_id]
163
- device_state['locked'] = locked
179
+ device_state['blocked'] = locked # Only save 'blocked', not 'locked'
180
+ # Remove old 'locked' field if it exists (gradual migration)
181
+ device_state.pop('locked', None)
164
182
  device_state['last_seen'] = now
165
183
  device_state['last_name'] = partition.name
166
184
  device_state['size_bytes'] = partition.size_bytes