voidremote 1.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.
- voidremote/__init__.py +31 -0
- voidremote/adb/__init__.py +28 -0
- voidremote/adb/client.py +385 -0
- voidremote/adb/device_parser.py +286 -0
- voidremote/cli/__init__.py +5 -0
- voidremote/cli/main.py +987 -0
- voidremote/config/__init__.py +37 -0
- voidremote/config/settings.py +203 -0
- voidremote/controllers/__init__.py +5 -0
- voidremote/controllers/app_controller.py +222 -0
- voidremote/core/__init__.py +1 -0
- voidremote/core/automation.py +184 -0
- voidremote/models/__init__.py +31 -0
- voidremote/models/device.py +263 -0
- voidremote/network/__init__.py +1 -0
- voidremote/network/discovery.py +116 -0
- voidremote/services/__init__.py +13 -0
- voidremote/services/device_service.py +340 -0
- voidremote/services/input_service.py +181 -0
- voidremote/services/monitor_service.py +198 -0
- voidremote/ui/__init__.py +1 -0
- voidremote/ui/app.py +70 -0
- voidremote/ui/dialogs/__init__.py +6 -0
- voidremote/ui/dialogs/connect_dialog.py +149 -0
- voidremote/ui/dialogs/pair_dialog.py +189 -0
- voidremote/ui/main_window.py +371 -0
- voidremote/ui/theme.py +529 -0
- voidremote/ui/views/__init__.py +15 -0
- voidremote/ui/views/dashboard_view.py +233 -0
- voidremote/ui/views/files_view.py +305 -0
- voidremote/ui/views/monitor_view.py +236 -0
- voidremote/ui/views/settings_view.py +257 -0
- voidremote/ui/views/shell_view.py +234 -0
- voidremote/ui/widgets/__init__.py +14 -0
- voidremote/ui/widgets/device_card.py +259 -0
- voidremote/ui/widgets/log_view.py +159 -0
- voidremote/ui/widgets/metric_gauge.py +90 -0
- voidremote/utils/__init__.py +28 -0
- voidremote/utils/logging.py +120 -0
- voidremote/utils/security.py +216 -0
- voidremote-1.0.0.dist-info/METADATA +578 -0
- voidremote-1.0.0.dist-info/RECORD +46 -0
- voidremote-1.0.0.dist-info/WHEEL +5 -0
- voidremote-1.0.0.dist-info/entry_points.txt +5 -0
- voidremote-1.0.0.dist-info/licenses/LICENSE +21 -0
- voidremote-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Device data models for VoidRemote.
|
|
3
|
+
|
|
4
|
+
Defines strongly-typed Pydantic models for Android device representation,
|
|
5
|
+
connection state, and device capabilities.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from enum import Enum, auto
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, Field, field_validator
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DeviceState(str, Enum):
|
|
19
|
+
"""Connection state of an ADB device."""
|
|
20
|
+
|
|
21
|
+
ONLINE = "online"
|
|
22
|
+
OFFLINE = "offline"
|
|
23
|
+
UNAUTHORIZED = "unauthorized"
|
|
24
|
+
CONNECTING = "connecting"
|
|
25
|
+
PAIRING = "pairing"
|
|
26
|
+
DISCONNECTED = "disconnected"
|
|
27
|
+
UNKNOWN = "unknown"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ConnectionType(str, Enum):
|
|
31
|
+
"""How the device is connected."""
|
|
32
|
+
|
|
33
|
+
USB = "usb"
|
|
34
|
+
WIRELESS = "wireless"
|
|
35
|
+
EMULATOR = "emulator"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DeviceCapability(str, Enum):
|
|
39
|
+
"""Optional capabilities a device may support."""
|
|
40
|
+
|
|
41
|
+
SCREEN_MIRROR = "screen_mirror"
|
|
42
|
+
FILE_TRANSFER = "file_transfer"
|
|
43
|
+
SHELL = "shell"
|
|
44
|
+
INPUT_CONTROL = "input_control"
|
|
45
|
+
PACKAGE_MANAGER = "package_manager"
|
|
46
|
+
MONITORING = "monitoring"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class BatteryInfo(BaseModel):
|
|
50
|
+
"""Battery status information."""
|
|
51
|
+
|
|
52
|
+
level: int = Field(default=0, ge=0, le=100, description="Battery percentage 0-100")
|
|
53
|
+
is_charging: bool = Field(default=False)
|
|
54
|
+
is_ac_powered: bool = Field(default=False)
|
|
55
|
+
is_usb_powered: bool = Field(default=False)
|
|
56
|
+
voltage: float = Field(default=0.0, description="Voltage in mV")
|
|
57
|
+
temperature: float = Field(default=0.0, description="Temperature in tenths of degree Celsius")
|
|
58
|
+
health: str = Field(default="unknown")
|
|
59
|
+
technology: str = Field(default="unknown")
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def temperature_celsius(self) -> float:
|
|
63
|
+
"""Return temperature in Celsius."""
|
|
64
|
+
return self.temperature / 10.0
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def voltage_volts(self) -> float:
|
|
68
|
+
"""Return voltage in Volts."""
|
|
69
|
+
return self.voltage / 1000.0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class StorageInfo(BaseModel):
|
|
73
|
+
"""Storage usage information."""
|
|
74
|
+
|
|
75
|
+
total_bytes: int = Field(default=0, ge=0)
|
|
76
|
+
available_bytes: int = Field(default=0, ge=0)
|
|
77
|
+
used_bytes: int = Field(default=0, ge=0)
|
|
78
|
+
mount_point: str = Field(default="/data")
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def total_gb(self) -> float:
|
|
82
|
+
return self.total_bytes / (1024**3)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def available_gb(self) -> float:
|
|
86
|
+
return self.available_bytes / (1024**3)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def used_gb(self) -> float:
|
|
90
|
+
return self.used_bytes / (1024**3)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def usage_percent(self) -> float:
|
|
94
|
+
if self.total_bytes == 0:
|
|
95
|
+
return 0.0
|
|
96
|
+
return (self.used_bytes / self.total_bytes) * 100.0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class CpuInfo(BaseModel):
|
|
100
|
+
"""CPU information."""
|
|
101
|
+
|
|
102
|
+
abi: str = Field(default="unknown", description="CPU ABI (e.g. arm64-v8a)")
|
|
103
|
+
abi_list: list[str] = Field(default_factory=list)
|
|
104
|
+
cores: int = Field(default=0, ge=0)
|
|
105
|
+
governor: str = Field(default="unknown")
|
|
106
|
+
usage_percent: float = Field(default=0.0, ge=0.0, le=100.0)
|
|
107
|
+
frequency_mhz: float = Field(default=0.0, ge=0.0)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class NetworkInfo(BaseModel):
|
|
111
|
+
"""Network connection information."""
|
|
112
|
+
|
|
113
|
+
ip_address: str = Field(default="")
|
|
114
|
+
wifi_ssid: str = Field(default="")
|
|
115
|
+
wifi_signal: int = Field(default=0, description="WiFi signal strength in dBm")
|
|
116
|
+
mac_address: str = Field(default="")
|
|
117
|
+
interface: str = Field(default="wlan0")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class DeviceInfo(BaseModel):
|
|
121
|
+
"""Complete device information."""
|
|
122
|
+
|
|
123
|
+
serial: str = Field(description="ADB serial identifier")
|
|
124
|
+
model: str = Field(default="Unknown")
|
|
125
|
+
manufacturer: str = Field(default="Unknown")
|
|
126
|
+
brand: str = Field(default="Unknown")
|
|
127
|
+
product: str = Field(default="Unknown")
|
|
128
|
+
device_name: str = Field(default="Unknown")
|
|
129
|
+
android_version: str = Field(default="Unknown")
|
|
130
|
+
sdk_version: int = Field(default=0)
|
|
131
|
+
build_id: str = Field(default="Unknown")
|
|
132
|
+
build_fingerprint: str = Field(default="")
|
|
133
|
+
screen_width: int = Field(default=0, ge=0)
|
|
134
|
+
screen_height: int = Field(default=0, ge=0)
|
|
135
|
+
screen_density: int = Field(default=0, ge=0, description="DPI")
|
|
136
|
+
battery: BatteryInfo = Field(default_factory=BatteryInfo)
|
|
137
|
+
storage: StorageInfo = Field(default_factory=StorageInfo)
|
|
138
|
+
cpu: CpuInfo = Field(default_factory=CpuInfo)
|
|
139
|
+
network: NetworkInfo = Field(default_factory=NetworkInfo)
|
|
140
|
+
ram_total_bytes: int = Field(default=0, ge=0)
|
|
141
|
+
ram_available_bytes: int = Field(default=0, ge=0)
|
|
142
|
+
features: list[str] = Field(default_factory=list)
|
|
143
|
+
|
|
144
|
+
@field_validator("serial")
|
|
145
|
+
@classmethod
|
|
146
|
+
def validate_serial(cls, v: str) -> str:
|
|
147
|
+
"""Ensure serial is not empty."""
|
|
148
|
+
if not v.strip():
|
|
149
|
+
raise ValueError("Device serial cannot be empty")
|
|
150
|
+
return v.strip()
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def display_name(self) -> str:
|
|
154
|
+
"""Human-readable device name."""
|
|
155
|
+
if self.model and self.model != "Unknown":
|
|
156
|
+
return f"{self.manufacturer} {self.model}".strip()
|
|
157
|
+
return self.serial
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def screen_resolution(self) -> str:
|
|
161
|
+
"""Screen resolution as string."""
|
|
162
|
+
if self.screen_width and self.screen_height:
|
|
163
|
+
return f"{self.screen_width}x{self.screen_height}"
|
|
164
|
+
return "Unknown"
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def ram_total_gb(self) -> float:
|
|
168
|
+
return self.ram_total_bytes / (1024**3)
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def ram_available_gb(self) -> float:
|
|
172
|
+
return self.ram_available_bytes / (1024**3)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class Device(BaseModel):
|
|
176
|
+
"""A connected ADB device with full state."""
|
|
177
|
+
|
|
178
|
+
info: DeviceInfo
|
|
179
|
+
state: DeviceState = Field(default=DeviceState.UNKNOWN)
|
|
180
|
+
connection_type: ConnectionType = Field(default=ConnectionType.USB)
|
|
181
|
+
host: str = Field(default="")
|
|
182
|
+
port: int = Field(default=5555, ge=1, le=65535)
|
|
183
|
+
is_trusted: bool = Field(default=False)
|
|
184
|
+
connected_at: Optional[datetime] = Field(default=None)
|
|
185
|
+
last_seen: Optional[datetime] = Field(default=None)
|
|
186
|
+
capabilities: list[DeviceCapability] = Field(default_factory=list)
|
|
187
|
+
tags: list[str] = Field(default_factory=list)
|
|
188
|
+
alias: Optional[str] = Field(default=None)
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def serial(self) -> str:
|
|
192
|
+
return self.info.serial
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def display_name(self) -> str:
|
|
196
|
+
return self.alias or self.info.display_name
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def is_connected(self) -> bool:
|
|
200
|
+
return self.state == DeviceState.ONLINE
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def adb_address(self) -> str:
|
|
204
|
+
"""Return ADB network address for wireless devices."""
|
|
205
|
+
if self.connection_type == ConnectionType.WIRELESS and self.host:
|
|
206
|
+
return f"{self.host}:{self.port}"
|
|
207
|
+
return self.serial
|
|
208
|
+
|
|
209
|
+
def has_capability(self, cap: DeviceCapability) -> bool:
|
|
210
|
+
"""Check if device supports a capability."""
|
|
211
|
+
return cap in self.capabilities
|
|
212
|
+
|
|
213
|
+
class Config:
|
|
214
|
+
use_enum_values = False
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass
|
|
218
|
+
class PairingInfo:
|
|
219
|
+
"""Information needed to pair a device over wireless debugging."""
|
|
220
|
+
|
|
221
|
+
host: str
|
|
222
|
+
port: int
|
|
223
|
+
pairing_code: str
|
|
224
|
+
timeout: float = 30.0
|
|
225
|
+
|
|
226
|
+
def __post_init__(self) -> None:
|
|
227
|
+
if not self.host:
|
|
228
|
+
raise ValueError("Host cannot be empty")
|
|
229
|
+
if not (1 <= self.port <= 65535):
|
|
230
|
+
raise ValueError(f"Invalid port: {self.port}")
|
|
231
|
+
if not self.pairing_code:
|
|
232
|
+
raise ValueError("Pairing code cannot be empty")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@dataclass
|
|
236
|
+
class ConnectionConfig:
|
|
237
|
+
"""Configuration for an ADB connection."""
|
|
238
|
+
|
|
239
|
+
host: str
|
|
240
|
+
port: int = 5555
|
|
241
|
+
timeout: float = 10.0
|
|
242
|
+
retry_count: int = 3
|
|
243
|
+
retry_delay: float = 1.0
|
|
244
|
+
keep_alive: bool = True
|
|
245
|
+
|
|
246
|
+
def __post_init__(self) -> None:
|
|
247
|
+
if not self.host:
|
|
248
|
+
raise ValueError("Host cannot be empty")
|
|
249
|
+
if not (1 <= self.port <= 65535):
|
|
250
|
+
raise ValueError(f"Invalid port: {self.port}")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@dataclass
|
|
254
|
+
class TrustedDevice:
|
|
255
|
+
"""A remembered/trusted device for auto-reconnect."""
|
|
256
|
+
|
|
257
|
+
serial: str
|
|
258
|
+
host: str
|
|
259
|
+
port: int
|
|
260
|
+
alias: Optional[str] = None
|
|
261
|
+
last_connected: Optional[datetime] = None
|
|
262
|
+
auto_connect: bool = True
|
|
263
|
+
tags: list[str] = field(default_factory=list)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""VoidRemote network utilities."""
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Network device discovery utilities.
|
|
3
|
+
|
|
4
|
+
Scans the local subnet for Android devices with ADB enabled
|
|
5
|
+
using TCP port scanning on common ADB ports.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ipaddress
|
|
11
|
+
import logging
|
|
12
|
+
import socket
|
|
13
|
+
import threading
|
|
14
|
+
from typing import Callable, Optional
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
ADB_PORTS = [5555, 5556, 5557, 5558, 5559]
|
|
19
|
+
DEFAULT_TIMEOUT = 0.5
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _check_port(host: str, port: int, timeout: float = DEFAULT_TIMEOUT) -> bool:
|
|
23
|
+
"""Return True if TCP port is open on host."""
|
|
24
|
+
try:
|
|
25
|
+
with socket.create_connection((host, port), timeout=timeout):
|
|
26
|
+
return True
|
|
27
|
+
except (OSError, ConnectionRefusedError, TimeoutError):
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def scan_host(host: str, ports: list[int] = ADB_PORTS) -> list[tuple[str, int]]:
|
|
32
|
+
"""
|
|
33
|
+
Scan a single host for open ADB ports.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
List of (host, port) tuples where ADB appears to be listening.
|
|
37
|
+
"""
|
|
38
|
+
results: list[tuple[str, int]] = []
|
|
39
|
+
for port in ports:
|
|
40
|
+
if _check_port(host, port):
|
|
41
|
+
results.append((host, port))
|
|
42
|
+
return results
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def scan_subnet(
|
|
46
|
+
subnet: str,
|
|
47
|
+
ports: list[int] = ADB_PORTS,
|
|
48
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
49
|
+
max_workers: int = 64,
|
|
50
|
+
on_found: Optional[Callable[[str, int], None]] = None,
|
|
51
|
+
on_progress: Optional[Callable[[int, int], None]] = None,
|
|
52
|
+
) -> list[tuple[str, int]]:
|
|
53
|
+
"""
|
|
54
|
+
Scan an entire subnet for ADB devices.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
subnet: CIDR notation subnet (e.g. "192.168.1.0/24").
|
|
58
|
+
ports: List of ports to check.
|
|
59
|
+
timeout: Per-connection timeout in seconds.
|
|
60
|
+
max_workers: Maximum concurrent scan threads.
|
|
61
|
+
on_found: Optional callback called when a device is discovered.
|
|
62
|
+
on_progress: Optional callback(scanned, total) for progress.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
List of (host, port) tuples with open ADB ports.
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
network = ipaddress.ip_network(subnet, strict=False)
|
|
69
|
+
except ValueError as exc:
|
|
70
|
+
raise ValueError(f"Invalid subnet: {subnet!r}") from exc
|
|
71
|
+
|
|
72
|
+
hosts = list(network.hosts())
|
|
73
|
+
total = len(hosts)
|
|
74
|
+
results: list[tuple[str, int]] = []
|
|
75
|
+
scanned = 0
|
|
76
|
+
lock = threading.Lock()
|
|
77
|
+
semaphore = threading.Semaphore(max_workers)
|
|
78
|
+
|
|
79
|
+
def scan_one(host: str) -> None:
|
|
80
|
+
nonlocal scanned
|
|
81
|
+
with semaphore:
|
|
82
|
+
found = scan_host(host, ports)
|
|
83
|
+
with lock:
|
|
84
|
+
scanned += 1
|
|
85
|
+
results.extend(found)
|
|
86
|
+
if on_found and found:
|
|
87
|
+
for h, p in found:
|
|
88
|
+
on_found(h, p)
|
|
89
|
+
if on_progress:
|
|
90
|
+
on_progress(scanned, total)
|
|
91
|
+
|
|
92
|
+
threads = [threading.Thread(target=scan_one, args=(str(h),), daemon=True) for h in hosts]
|
|
93
|
+
for t in threads:
|
|
94
|
+
t.start()
|
|
95
|
+
for t in threads:
|
|
96
|
+
t.join()
|
|
97
|
+
|
|
98
|
+
logger.info("Subnet scan %s complete: %d hosts scanned, %d found", subnet, total, len(results))
|
|
99
|
+
return results
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_local_subnet() -> Optional[str]:
|
|
103
|
+
"""
|
|
104
|
+
Attempt to determine the local network subnet.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
CIDR string like "192.168.1.0/24", or None if not determinable.
|
|
108
|
+
"""
|
|
109
|
+
try:
|
|
110
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
|
111
|
+
s.connect(("8.8.8.8", 80))
|
|
112
|
+
local_ip = s.getsockname()[0]
|
|
113
|
+
parts = local_ip.split(".")
|
|
114
|
+
return f"{parts[0]}.{parts[1]}.{parts[2]}.0/24"
|
|
115
|
+
except Exception:
|
|
116
|
+
return None
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""VoidRemote service layer."""
|
|
2
|
+
|
|
3
|
+
from voidremote.services.device_service import DeviceService
|
|
4
|
+
from voidremote.services.input_service import InputService, KeyCode
|
|
5
|
+
from voidremote.services.monitor_service import DeviceSnapshot, MonitorService
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"DeviceService",
|
|
9
|
+
"DeviceSnapshot",
|
|
10
|
+
"InputService",
|
|
11
|
+
"KeyCode",
|
|
12
|
+
"MonitorService",
|
|
13
|
+
]
|