plexus-python 0.1.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.
- plexus/__init__.py +31 -0
- plexus/__main__.py +4 -0
- plexus/adapters/__init__.py +122 -0
- plexus/adapters/base.py +409 -0
- plexus/adapters/ble.py +257 -0
- plexus/adapters/can.py +439 -0
- plexus/adapters/can_detect.py +174 -0
- plexus/adapters/mavlink.py +642 -0
- plexus/adapters/mavlink_detect.py +192 -0
- plexus/adapters/modbus.py +622 -0
- plexus/adapters/mqtt.py +350 -0
- plexus/adapters/opcua.py +607 -0
- plexus/adapters/registry.py +206 -0
- plexus/adapters/serial_adapter.py +547 -0
- plexus/buffer.py +257 -0
- plexus/cameras/__init__.py +57 -0
- plexus/cameras/auto.py +239 -0
- plexus/cameras/base.py +189 -0
- plexus/cameras/picamera.py +171 -0
- plexus/cameras/usb.py +143 -0
- plexus/cli.py +783 -0
- plexus/client.py +465 -0
- plexus/config.py +169 -0
- plexus/connector.py +666 -0
- plexus/deps.py +246 -0
- plexus/detect.py +1238 -0
- plexus/importers/__init__.py +25 -0
- plexus/importers/rosbag.py +778 -0
- plexus/sensors/__init__.py +118 -0
- plexus/sensors/ads1115.py +164 -0
- plexus/sensors/adxl345.py +179 -0
- plexus/sensors/auto.py +290 -0
- plexus/sensors/base.py +412 -0
- plexus/sensors/bh1750.py +102 -0
- plexus/sensors/bme280.py +241 -0
- plexus/sensors/gps.py +317 -0
- plexus/sensors/ina219.py +149 -0
- plexus/sensors/magnetometer.py +239 -0
- plexus/sensors/mpu6050.py +162 -0
- plexus/sensors/sht3x.py +139 -0
- plexus/sensors/spi_scan.py +164 -0
- plexus/sensors/system.py +261 -0
- plexus/sensors/vl53l0x.py +109 -0
- plexus/streaming.py +743 -0
- plexus/tui.py +642 -0
- plexus_python-0.1.0.dist-info/METADATA +470 -0
- plexus_python-0.1.0.dist-info/RECORD +50 -0
- plexus_python-0.1.0.dist-info/WHEEL +4 -0
- plexus_python-0.1.0.dist-info/entry_points.txt +2 -0
- plexus_python-0.1.0.dist-info/licenses/LICENSE +190 -0
plexus/detect.py
ADDED
|
@@ -0,0 +1,1238 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hardware detection for Plexus devices.
|
|
3
|
+
|
|
4
|
+
Detects sensors, cameras, serial ports, USB devices, GPIO, Bluetooth,
|
|
5
|
+
network interfaces, and system info. Used by the CLI for both
|
|
6
|
+
`plexus start` and `plexus scan`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import glob
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import platform
|
|
13
|
+
import shutil
|
|
14
|
+
import socket
|
|
15
|
+
import subprocess
|
|
16
|
+
from dataclasses import dataclass, field, asdict
|
|
17
|
+
from typing import List, Optional, Tuple, Dict, Any, TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from plexus.sensors.base import SensorHub
|
|
23
|
+
from plexus.cameras.base import CameraHub
|
|
24
|
+
from plexus.adapters.can_detect import DetectedCAN
|
|
25
|
+
from plexus.adapters.mavlink_detect import DetectedMAVLink
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
# Data Classes
|
|
30
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class SensorInfo:
|
|
34
|
+
"""Lightweight info object for display (matches DetectedSensor pattern)."""
|
|
35
|
+
name: str
|
|
36
|
+
description: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class SerialDevice:
|
|
41
|
+
"""A detected serial port."""
|
|
42
|
+
port: str
|
|
43
|
+
description: str = ""
|
|
44
|
+
hwid: str = ""
|
|
45
|
+
manufacturer: str = ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class USBDevice:
|
|
50
|
+
"""A detected USB device."""
|
|
51
|
+
name: str
|
|
52
|
+
vendor_id: str = ""
|
|
53
|
+
product_id: str = ""
|
|
54
|
+
manufacturer: str = ""
|
|
55
|
+
serial: str = ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class GPIOInfo:
|
|
60
|
+
"""GPIO availability information."""
|
|
61
|
+
chip: str
|
|
62
|
+
num_lines: int = 0
|
|
63
|
+
used_lines: int = 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class BluetoothDevice:
|
|
68
|
+
"""A detected Bluetooth/BLE device."""
|
|
69
|
+
name: str
|
|
70
|
+
address: str
|
|
71
|
+
rssi: Optional[int] = None
|
|
72
|
+
type: str = "classic" # "classic", "le", or "dual"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class NetworkInterface:
|
|
77
|
+
"""A detected network interface."""
|
|
78
|
+
name: str
|
|
79
|
+
type: str = "unknown" # "ethernet", "wifi", "can", "loopback", "other"
|
|
80
|
+
ip: str = ""
|
|
81
|
+
mac: str = ""
|
|
82
|
+
state: str = "unknown" # "up", "down", "unknown"
|
|
83
|
+
extra: Dict[str, Any] = field(default_factory=dict)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class SystemInfo:
|
|
88
|
+
"""Comprehensive system information."""
|
|
89
|
+
hostname: str = ""
|
|
90
|
+
platform: str = ""
|
|
91
|
+
arch: str = ""
|
|
92
|
+
cpu: str = ""
|
|
93
|
+
cpu_cores: int = 0
|
|
94
|
+
ram_mb: int = 0
|
|
95
|
+
disk_gb: float = 0.0
|
|
96
|
+
os_version: str = ""
|
|
97
|
+
python_version: str = ""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
101
|
+
# Existing Detection Functions
|
|
102
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
def detect_sensors(bus: Optional[int] = None) -> Tuple[Optional["SensorHub"], List]:
|
|
105
|
+
"""Detect I2C sensors and create a SensorHub.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
bus: I2C bus number, or None to scan all available buses.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
(sensor_hub or None, list of detected sensor info objects)
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
ImportError: If smbus2 is not installed.
|
|
115
|
+
PermissionError: If I2C bus is not accessible.
|
|
116
|
+
"""
|
|
117
|
+
from plexus.sensors import scan_sensors, auto_sensors
|
|
118
|
+
sensors = scan_sensors(bus)
|
|
119
|
+
if sensors:
|
|
120
|
+
hub = auto_sensors(detected=sensors)
|
|
121
|
+
return hub, sensors
|
|
122
|
+
return None, []
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def detect_cameras() -> Tuple[Optional["CameraHub"], List]:
|
|
126
|
+
"""Detect connected cameras and create a CameraHub.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
(camera_hub or None, list of detected camera info objects)
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
ImportError: If camera libraries (picamera2, opencv-python) are missing.
|
|
133
|
+
"""
|
|
134
|
+
from plexus.cameras import scan_cameras, auto_cameras
|
|
135
|
+
cameras = scan_cameras()
|
|
136
|
+
if cameras:
|
|
137
|
+
hub = auto_cameras()
|
|
138
|
+
return hub, cameras
|
|
139
|
+
return None, []
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def detect_can() -> Tuple[Optional[List["DetectedCAN"]], List["DetectedCAN"], List["DetectedCAN"]]:
|
|
143
|
+
"""Detect CAN interfaces.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
(up_adapters or None, up_list, down_list)
|
|
147
|
+
up_adapters is None if no active interfaces found.
|
|
148
|
+
"""
|
|
149
|
+
try:
|
|
150
|
+
from plexus.adapters.can_detect import scan_can
|
|
151
|
+
detected = scan_can()
|
|
152
|
+
up = [c for c in detected if c.is_up]
|
|
153
|
+
down = [c for c in detected if not c.is_up]
|
|
154
|
+
return (up if up else None), up, down
|
|
155
|
+
except Exception as e:
|
|
156
|
+
logger.debug(f"CAN detection failed: {e}")
|
|
157
|
+
return None, [], []
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def detect_mavlink() -> List["DetectedMAVLink"]:
|
|
161
|
+
"""Detect MAVLink connections (UDP, TCP, serial).
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
List of detected MAVLink connections.
|
|
165
|
+
"""
|
|
166
|
+
try:
|
|
167
|
+
from plexus.adapters.mavlink_detect import scan_mavlink
|
|
168
|
+
return scan_mavlink()
|
|
169
|
+
except Exception as e:
|
|
170
|
+
logger.debug(f"MAVLink detection failed: {e}")
|
|
171
|
+
return []
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def detect_named_sensors(
|
|
175
|
+
sensor_types: List[str],
|
|
176
|
+
) -> Tuple[Optional["SensorHub"], List[SensorInfo]]:
|
|
177
|
+
"""Create a SensorHub from explicit --sensor CLI arguments.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
sensor_types: List of sensor type names (e.g. ["system"])
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
(sensor_hub or None, list of SensorInfo for display)
|
|
184
|
+
"""
|
|
185
|
+
from plexus.sensors import SENSOR_REGISTRY, SensorHub
|
|
186
|
+
|
|
187
|
+
hub = SensorHub()
|
|
188
|
+
info_list = []
|
|
189
|
+
|
|
190
|
+
for sensor_type in sensor_types:
|
|
191
|
+
sensor_type = sensor_type.lower()
|
|
192
|
+
if sensor_type not in SENSOR_REGISTRY:
|
|
193
|
+
valid = ", ".join(sorted(SENSOR_REGISTRY.keys()))
|
|
194
|
+
raise ValueError(f"Unknown sensor type '{sensor_type}'. Valid types: {valid}")
|
|
195
|
+
|
|
196
|
+
driver_class = SENSOR_REGISTRY[sensor_type]
|
|
197
|
+
sensor = driver_class()
|
|
198
|
+
hub.add(sensor)
|
|
199
|
+
info_list.append(SensorInfo(name=sensor.name, description=sensor.description))
|
|
200
|
+
|
|
201
|
+
if not info_list:
|
|
202
|
+
return None, []
|
|
203
|
+
|
|
204
|
+
return hub, info_list
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
208
|
+
# Config Serialization
|
|
209
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
def sensors_to_config(sensors: List) -> List[str]:
|
|
212
|
+
"""Serialize detected sensors to config strings.
|
|
213
|
+
|
|
214
|
+
Format: "name" for named sensors, "name:0xADDR" for I2C sensors.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
sensors: List of DetectedSensor or SensorInfo objects.
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
List of config spec strings (e.g. ["mpu6050:0x68", "system"]).
|
|
221
|
+
"""
|
|
222
|
+
specs = []
|
|
223
|
+
for s in sensors:
|
|
224
|
+
address = getattr(s, "address", None)
|
|
225
|
+
if address and address > 0:
|
|
226
|
+
name = s.name.lower().replace(" ", "_")
|
|
227
|
+
bus = getattr(s, "bus", 1)
|
|
228
|
+
spec = f"{name}:0x{address:02x}"
|
|
229
|
+
if bus != 1:
|
|
230
|
+
spec += f":{bus}"
|
|
231
|
+
specs.append(spec)
|
|
232
|
+
else:
|
|
233
|
+
# Named sensor (system, gps, etc.)
|
|
234
|
+
specs.append(s.name.lower().replace(" ", "_"))
|
|
235
|
+
return specs
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def load_sensors_from_config(
|
|
239
|
+
specs: List[str],
|
|
240
|
+
) -> Tuple[Optional["SensorHub"], List[SensorInfo]]:
|
|
241
|
+
"""Build a SensorHub from config spec strings.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
specs: List of sensor specs (e.g. ["mpu6050:0x68", "system"]).
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
(sensor_hub or None, list of SensorInfo for display)
|
|
248
|
+
"""
|
|
249
|
+
from plexus.sensors import SENSOR_REGISTRY, SensorHub
|
|
250
|
+
|
|
251
|
+
hub = SensorHub()
|
|
252
|
+
info_list = []
|
|
253
|
+
|
|
254
|
+
for spec in specs:
|
|
255
|
+
parts = spec.split(":")
|
|
256
|
+
name = parts[0].lower()
|
|
257
|
+
|
|
258
|
+
if name not in SENSOR_REGISTRY:
|
|
259
|
+
logger.warning("Unknown sensor '%s' in config, skipping", spec)
|
|
260
|
+
continue
|
|
261
|
+
|
|
262
|
+
driver_class = SENSOR_REGISTRY[name]
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
if len(parts) >= 2:
|
|
266
|
+
# I2C sensor: "name:0xADDR" or "name:0xADDR:BUS"
|
|
267
|
+
address = int(parts[1], 16)
|
|
268
|
+
bus = int(parts[2]) if len(parts) > 2 else 1
|
|
269
|
+
sensor = driver_class(address=address, bus=bus)
|
|
270
|
+
else:
|
|
271
|
+
# Named sensor: "system", "gps", etc.
|
|
272
|
+
sensor = driver_class()
|
|
273
|
+
|
|
274
|
+
if sensor.is_available():
|
|
275
|
+
hub.add(sensor)
|
|
276
|
+
info_list.append(SensorInfo(
|
|
277
|
+
name=sensor.name,
|
|
278
|
+
description=sensor.description,
|
|
279
|
+
))
|
|
280
|
+
else:
|
|
281
|
+
logger.warning("%s not responding, skipping", spec)
|
|
282
|
+
except Exception as e:
|
|
283
|
+
logger.warning("Failed to initialize %s: %s, skipping", spec, e)
|
|
284
|
+
|
|
285
|
+
if not info_list:
|
|
286
|
+
return None, []
|
|
287
|
+
|
|
288
|
+
return hub, info_list
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
292
|
+
# Serial Port Detection
|
|
293
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
def detect_serial() -> List[SerialDevice]:
|
|
296
|
+
"""Detect serial ports: USB-UART, /dev/ttyUSB*, /dev/ttyACM*, etc.
|
|
297
|
+
|
|
298
|
+
Tries pyserial first for rich metadata, falls back to glob on /dev/tty*.
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
List of detected serial devices. Empty list on failure.
|
|
302
|
+
"""
|
|
303
|
+
# Method 1: pyserial (best — gives description, hwid, manufacturer)
|
|
304
|
+
try:
|
|
305
|
+
import serial.tools.list_ports
|
|
306
|
+
ports = serial.tools.list_ports.comports()
|
|
307
|
+
devices = []
|
|
308
|
+
for p in ports:
|
|
309
|
+
# Skip purely virtual/internal ports with no real hardware
|
|
310
|
+
if p.hwid == "n/a" and not p.description:
|
|
311
|
+
continue
|
|
312
|
+
devices.append(SerialDevice(
|
|
313
|
+
port=p.device,
|
|
314
|
+
description=p.description or "",
|
|
315
|
+
hwid=p.hwid or "",
|
|
316
|
+
manufacturer=p.manufacturer or "",
|
|
317
|
+
))
|
|
318
|
+
if devices:
|
|
319
|
+
logger.debug(f"Serial: pyserial found {len(devices)} port(s)")
|
|
320
|
+
return devices
|
|
321
|
+
# pyserial found nothing — still try glob fallback
|
|
322
|
+
except ImportError:
|
|
323
|
+
logger.debug("Serial: pyserial not available, falling back to glob")
|
|
324
|
+
except Exception as e:
|
|
325
|
+
logger.debug(f"Serial: pyserial failed: {e}")
|
|
326
|
+
|
|
327
|
+
# Method 2: Glob for common serial device paths
|
|
328
|
+
devices = []
|
|
329
|
+
patterns = [
|
|
330
|
+
"/dev/ttyUSB*",
|
|
331
|
+
"/dev/ttyACM*",
|
|
332
|
+
"/dev/tty.usbserial*",
|
|
333
|
+
"/dev/tty.usbmodem*",
|
|
334
|
+
"/dev/tty.SLAB*", # CP210x on macOS
|
|
335
|
+
"/dev/tty.wchusbserial*", # CH340 on macOS
|
|
336
|
+
]
|
|
337
|
+
for pattern in patterns:
|
|
338
|
+
for path in sorted(glob.glob(pattern)):
|
|
339
|
+
devices.append(SerialDevice(
|
|
340
|
+
port=path,
|
|
341
|
+
description=_serial_description_from_path(path),
|
|
342
|
+
))
|
|
343
|
+
|
|
344
|
+
if devices:
|
|
345
|
+
logger.debug(f"Serial: glob found {len(devices)} port(s)")
|
|
346
|
+
return devices
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _serial_description_from_path(path: str) -> str:
|
|
350
|
+
"""Infer a human-readable description from a serial device path."""
|
|
351
|
+
name = os.path.basename(path)
|
|
352
|
+
if "ttyUSB" in name:
|
|
353
|
+
return "USB-Serial adapter"
|
|
354
|
+
if "ttyACM" in name:
|
|
355
|
+
return "USB CDC device"
|
|
356
|
+
if "usbserial" in name:
|
|
357
|
+
return "USB-Serial adapter"
|
|
358
|
+
if "usbmodem" in name:
|
|
359
|
+
return "USB modem"
|
|
360
|
+
if "SLAB" in name:
|
|
361
|
+
return "CP210x USB-UART"
|
|
362
|
+
if "wchusbserial" in name:
|
|
363
|
+
return "CH340 USB-UART"
|
|
364
|
+
return "Serial port"
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
368
|
+
# USB Device Detection
|
|
369
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
def detect_usb() -> List[USBDevice]:
|
|
372
|
+
"""Detect USB devices connected to the system.
|
|
373
|
+
|
|
374
|
+
On Linux, reads /sys/bus/usb/devices. On macOS, uses system_profiler.
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
List of detected USB devices. Empty list on failure.
|
|
378
|
+
"""
|
|
379
|
+
system = platform.system()
|
|
380
|
+
|
|
381
|
+
if system == "Linux":
|
|
382
|
+
return _detect_usb_linux()
|
|
383
|
+
elif system == "Darwin":
|
|
384
|
+
return _detect_usb_macos()
|
|
385
|
+
else:
|
|
386
|
+
logger.debug(f"USB: unsupported platform {system}")
|
|
387
|
+
return []
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _detect_usb_linux() -> List[USBDevice]:
|
|
391
|
+
"""Detect USB devices on Linux via /sys/bus/usb/devices."""
|
|
392
|
+
devices = []
|
|
393
|
+
usb_path = "/sys/bus/usb/devices"
|
|
394
|
+
|
|
395
|
+
if not os.path.isdir(usb_path):
|
|
396
|
+
logger.debug("USB: /sys/bus/usb/devices not found")
|
|
397
|
+
return devices
|
|
398
|
+
|
|
399
|
+
try:
|
|
400
|
+
for entry in sorted(os.listdir(usb_path)):
|
|
401
|
+
dev_dir = os.path.join(usb_path, entry)
|
|
402
|
+
product_file = os.path.join(dev_dir, "product")
|
|
403
|
+
if not os.path.isfile(product_file):
|
|
404
|
+
continue
|
|
405
|
+
|
|
406
|
+
name = _read_sysfs(product_file) or "Unknown"
|
|
407
|
+
vendor_id = _read_sysfs(os.path.join(dev_dir, "idVendor")) or ""
|
|
408
|
+
product_id = _read_sysfs(os.path.join(dev_dir, "idProduct")) or ""
|
|
409
|
+
manufacturer = _read_sysfs(os.path.join(dev_dir, "manufacturer")) or ""
|
|
410
|
+
serial = _read_sysfs(os.path.join(dev_dir, "serial")) or ""
|
|
411
|
+
|
|
412
|
+
devices.append(USBDevice(
|
|
413
|
+
name=name,
|
|
414
|
+
vendor_id=vendor_id,
|
|
415
|
+
product_id=product_id,
|
|
416
|
+
manufacturer=manufacturer,
|
|
417
|
+
serial=serial,
|
|
418
|
+
))
|
|
419
|
+
except OSError as e:
|
|
420
|
+
logger.debug(f"USB: error reading sysfs: {e}")
|
|
421
|
+
|
|
422
|
+
logger.debug(f"USB: Linux sysfs found {len(devices)} device(s)")
|
|
423
|
+
return devices
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _detect_usb_macos() -> List[USBDevice]:
|
|
427
|
+
"""Detect USB devices on macOS via system_profiler."""
|
|
428
|
+
devices = []
|
|
429
|
+
try:
|
|
430
|
+
result = subprocess.run(
|
|
431
|
+
["system_profiler", "SPUSBDataType", "-detailLevel", "mini"],
|
|
432
|
+
capture_output=True, text=True, timeout=10,
|
|
433
|
+
)
|
|
434
|
+
if result.returncode != 0:
|
|
435
|
+
logger.debug(f"USB: system_profiler failed: {result.stderr}")
|
|
436
|
+
return devices
|
|
437
|
+
|
|
438
|
+
current_name = ""
|
|
439
|
+
current_vendor_id = ""
|
|
440
|
+
current_product_id = ""
|
|
441
|
+
current_manufacturer = ""
|
|
442
|
+
current_serial = ""
|
|
443
|
+
|
|
444
|
+
for line in result.stdout.splitlines():
|
|
445
|
+
stripped = line.strip()
|
|
446
|
+
|
|
447
|
+
# Device name lines end with ':' and are indented
|
|
448
|
+
if stripped.endswith(":") and not stripped.startswith("USB") and line.startswith(" "):
|
|
449
|
+
# Save previous device if any
|
|
450
|
+
if current_name:
|
|
451
|
+
devices.append(USBDevice(
|
|
452
|
+
name=current_name,
|
|
453
|
+
vendor_id=current_vendor_id,
|
|
454
|
+
product_id=current_product_id,
|
|
455
|
+
manufacturer=current_manufacturer,
|
|
456
|
+
serial=current_serial,
|
|
457
|
+
))
|
|
458
|
+
current_name = stripped.rstrip(":")
|
|
459
|
+
current_vendor_id = ""
|
|
460
|
+
current_product_id = ""
|
|
461
|
+
current_manufacturer = ""
|
|
462
|
+
current_serial = ""
|
|
463
|
+
elif "Vendor ID:" in stripped:
|
|
464
|
+
current_vendor_id = stripped.split(":", 1)[1].strip()
|
|
465
|
+
elif "Product ID:" in stripped:
|
|
466
|
+
current_product_id = stripped.split(":", 1)[1].strip()
|
|
467
|
+
elif "Manufacturer:" in stripped:
|
|
468
|
+
current_manufacturer = stripped.split(":", 1)[1].strip()
|
|
469
|
+
elif "Serial Number:" in stripped:
|
|
470
|
+
current_serial = stripped.split(":", 1)[1].strip()
|
|
471
|
+
|
|
472
|
+
# Save last device
|
|
473
|
+
if current_name:
|
|
474
|
+
devices.append(USBDevice(
|
|
475
|
+
name=current_name,
|
|
476
|
+
vendor_id=current_vendor_id,
|
|
477
|
+
product_id=current_product_id,
|
|
478
|
+
manufacturer=current_manufacturer,
|
|
479
|
+
serial=current_serial,
|
|
480
|
+
))
|
|
481
|
+
|
|
482
|
+
except FileNotFoundError:
|
|
483
|
+
logger.debug("USB: system_profiler not found")
|
|
484
|
+
except subprocess.TimeoutExpired:
|
|
485
|
+
logger.debug("USB: system_profiler timed out")
|
|
486
|
+
except Exception as e:
|
|
487
|
+
logger.debug(f"USB: system_profiler error: {e}")
|
|
488
|
+
|
|
489
|
+
logger.debug(f"USB: macOS found {len(devices)} device(s)")
|
|
490
|
+
return devices
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _read_sysfs(path: str) -> Optional[str]:
|
|
494
|
+
"""Read a sysfs file, returning None on failure."""
|
|
495
|
+
try:
|
|
496
|
+
with open(path, "r") as f:
|
|
497
|
+
return f.read().strip()
|
|
498
|
+
except (OSError, IOError):
|
|
499
|
+
return None
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
503
|
+
# GPIO Detection
|
|
504
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
505
|
+
|
|
506
|
+
def detect_gpio() -> List[GPIOInfo]:
|
|
507
|
+
"""Detect GPIO availability (Linux: /sys/class/gpio or gpiod).
|
|
508
|
+
|
|
509
|
+
Returns:
|
|
510
|
+
List of detected GPIO chips. Empty list if GPIO not available.
|
|
511
|
+
"""
|
|
512
|
+
# Method 1: gpiod Python bindings
|
|
513
|
+
try:
|
|
514
|
+
import gpiod
|
|
515
|
+
chips = []
|
|
516
|
+
for entry in sorted(glob.glob("/dev/gpiochip*")):
|
|
517
|
+
chip_name = os.path.basename(entry)
|
|
518
|
+
try:
|
|
519
|
+
chip = gpiod.Chip(entry)
|
|
520
|
+
num_lines = chip.num_lines if hasattr(chip, 'num_lines') else 0
|
|
521
|
+
chip.close()
|
|
522
|
+
chips.append(GPIOInfo(
|
|
523
|
+
chip=chip_name,
|
|
524
|
+
num_lines=num_lines,
|
|
525
|
+
))
|
|
526
|
+
except Exception as e:
|
|
527
|
+
logger.debug(f"GPIO: gpiod error on {chip_name}: {e}")
|
|
528
|
+
chips.append(GPIOInfo(chip=chip_name))
|
|
529
|
+
if chips:
|
|
530
|
+
logger.debug(f"GPIO: gpiod found {len(chips)} chip(s)")
|
|
531
|
+
return chips
|
|
532
|
+
except ImportError:
|
|
533
|
+
logger.debug("GPIO: gpiod not available, falling back to sysfs")
|
|
534
|
+
except Exception as e:
|
|
535
|
+
logger.debug(f"GPIO: gpiod failed: {e}")
|
|
536
|
+
|
|
537
|
+
# Method 2: sysfs /sys/class/gpio/gpiochip*
|
|
538
|
+
chips = []
|
|
539
|
+
for chip_path in sorted(glob.glob("/sys/class/gpio/gpiochip*")):
|
|
540
|
+
chip_name = os.path.basename(chip_path)
|
|
541
|
+
ngpio_str = _read_sysfs(os.path.join(chip_path, "ngpio"))
|
|
542
|
+
num_lines = int(ngpio_str) if ngpio_str and ngpio_str.isdigit() else 0
|
|
543
|
+
chips.append(GPIOInfo(
|
|
544
|
+
chip=chip_name,
|
|
545
|
+
num_lines=num_lines,
|
|
546
|
+
))
|
|
547
|
+
|
|
548
|
+
if chips:
|
|
549
|
+
logger.debug(f"GPIO: sysfs found {len(chips)} chip(s)")
|
|
550
|
+
|
|
551
|
+
return chips
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
555
|
+
# Bluetooth Detection
|
|
556
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
557
|
+
|
|
558
|
+
def detect_bluetooth() -> List[BluetoothDevice]:
|
|
559
|
+
"""Detect Bluetooth/BLE devices in range.
|
|
560
|
+
|
|
561
|
+
Tries Python bluetooth library, falls back to hcitool/bluetoothctl.
|
|
562
|
+
|
|
563
|
+
Returns:
|
|
564
|
+
List of discovered Bluetooth devices. Empty list on failure.
|
|
565
|
+
"""
|
|
566
|
+
# Method 1: PyBluez
|
|
567
|
+
try:
|
|
568
|
+
import bluetooth
|
|
569
|
+
nearby = bluetooth.discover_devices(duration=4, lookup_names=True, lookup_class=False)
|
|
570
|
+
devices = []
|
|
571
|
+
for addr, name in nearby:
|
|
572
|
+
devices.append(BluetoothDevice(
|
|
573
|
+
name=name or "Unknown",
|
|
574
|
+
address=addr,
|
|
575
|
+
type="classic",
|
|
576
|
+
))
|
|
577
|
+
if devices:
|
|
578
|
+
logger.debug(f"Bluetooth: PyBluez found {len(devices)} device(s)")
|
|
579
|
+
return devices
|
|
580
|
+
except ImportError:
|
|
581
|
+
logger.debug("Bluetooth: PyBluez not available")
|
|
582
|
+
except Exception as e:
|
|
583
|
+
logger.debug(f"Bluetooth: PyBluez failed: {e}")
|
|
584
|
+
|
|
585
|
+
# Method 2: bluetoothctl (Linux)
|
|
586
|
+
if platform.system() == "Linux":
|
|
587
|
+
try:
|
|
588
|
+
# Start a quick scan and list
|
|
589
|
+
result = subprocess.run(
|
|
590
|
+
["bluetoothctl", "devices"],
|
|
591
|
+
capture_output=True, text=True, timeout=5,
|
|
592
|
+
)
|
|
593
|
+
if result.returncode == 0:
|
|
594
|
+
devices = []
|
|
595
|
+
for line in result.stdout.strip().splitlines():
|
|
596
|
+
# Format: "Device AA:BB:CC:DD:EE:FF DeviceName"
|
|
597
|
+
parts = line.strip().split(None, 2)
|
|
598
|
+
if len(parts) >= 3 and parts[0] == "Device":
|
|
599
|
+
devices.append(BluetoothDevice(
|
|
600
|
+
name=parts[2],
|
|
601
|
+
address=parts[1],
|
|
602
|
+
type="classic",
|
|
603
|
+
))
|
|
604
|
+
if devices:
|
|
605
|
+
logger.debug(f"Bluetooth: bluetoothctl found {len(devices)} device(s)")
|
|
606
|
+
return devices
|
|
607
|
+
except FileNotFoundError:
|
|
608
|
+
logger.debug("Bluetooth: bluetoothctl not found")
|
|
609
|
+
except subprocess.TimeoutExpired:
|
|
610
|
+
logger.debug("Bluetooth: bluetoothctl timed out")
|
|
611
|
+
except Exception as e:
|
|
612
|
+
logger.debug(f"Bluetooth: bluetoothctl failed: {e}")
|
|
613
|
+
|
|
614
|
+
# Method 3: macOS system_profiler
|
|
615
|
+
if platform.system() == "Darwin":
|
|
616
|
+
try:
|
|
617
|
+
result = subprocess.run(
|
|
618
|
+
["system_profiler", "SPBluetoothDataType"],
|
|
619
|
+
capture_output=True, text=True, timeout=10,
|
|
620
|
+
)
|
|
621
|
+
if result.returncode == 0:
|
|
622
|
+
devices = []
|
|
623
|
+
current_name = ""
|
|
624
|
+
current_address = ""
|
|
625
|
+
in_devices_section = False
|
|
626
|
+
|
|
627
|
+
for line in result.stdout.splitlines():
|
|
628
|
+
stripped = line.strip()
|
|
629
|
+
if "Connected:" in stripped or "Devices" in stripped:
|
|
630
|
+
in_devices_section = True
|
|
631
|
+
continue
|
|
632
|
+
if in_devices_section:
|
|
633
|
+
if stripped.endswith(":") and "Address" not in stripped:
|
|
634
|
+
if current_name and current_address:
|
|
635
|
+
devices.append(BluetoothDevice(
|
|
636
|
+
name=current_name,
|
|
637
|
+
address=current_address,
|
|
638
|
+
type="classic",
|
|
639
|
+
))
|
|
640
|
+
current_name = stripped.rstrip(":")
|
|
641
|
+
current_address = ""
|
|
642
|
+
elif "Address:" in stripped:
|
|
643
|
+
current_address = stripped.split(":", 1)[1].strip()
|
|
644
|
+
|
|
645
|
+
if current_name and current_address:
|
|
646
|
+
devices.append(BluetoothDevice(
|
|
647
|
+
name=current_name,
|
|
648
|
+
address=current_address,
|
|
649
|
+
type="classic",
|
|
650
|
+
))
|
|
651
|
+
|
|
652
|
+
if devices:
|
|
653
|
+
logger.debug(f"Bluetooth: macOS found {len(devices)} device(s)")
|
|
654
|
+
return devices
|
|
655
|
+
except FileNotFoundError:
|
|
656
|
+
pass
|
|
657
|
+
except subprocess.TimeoutExpired:
|
|
658
|
+
pass
|
|
659
|
+
except Exception as e:
|
|
660
|
+
logger.debug(f"Bluetooth: macOS detection failed: {e}")
|
|
661
|
+
|
|
662
|
+
return []
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
666
|
+
# Network Interface Detection
|
|
667
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
668
|
+
|
|
669
|
+
def detect_network() -> List[NetworkInterface]:
|
|
670
|
+
"""Detect network interfaces with details.
|
|
671
|
+
|
|
672
|
+
Uses psutil if available, falls back to `ip addr` (Linux) or
|
|
673
|
+
`ifconfig` (macOS).
|
|
674
|
+
|
|
675
|
+
Returns:
|
|
676
|
+
List of detected network interfaces. Empty list on failure.
|
|
677
|
+
"""
|
|
678
|
+
# Method 1: psutil (best — cross-platform, rich data)
|
|
679
|
+
try:
|
|
680
|
+
import psutil
|
|
681
|
+
return _detect_network_psutil(psutil)
|
|
682
|
+
except ImportError:
|
|
683
|
+
logger.debug("Network: psutil not available, falling back to system commands")
|
|
684
|
+
except Exception as e:
|
|
685
|
+
logger.debug(f"Network: psutil failed: {e}")
|
|
686
|
+
|
|
687
|
+
# Method 2: Platform-specific commands
|
|
688
|
+
system = platform.system()
|
|
689
|
+
if system == "Linux":
|
|
690
|
+
return _detect_network_linux()
|
|
691
|
+
elif system == "Darwin":
|
|
692
|
+
return _detect_network_macos()
|
|
693
|
+
|
|
694
|
+
return []
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _detect_network_psutil(psutil) -> List[NetworkInterface]:
|
|
698
|
+
"""Detect network interfaces using psutil."""
|
|
699
|
+
interfaces = []
|
|
700
|
+
stats = psutil.net_if_stats()
|
|
701
|
+
addrs = psutil.net_if_addrs()
|
|
702
|
+
|
|
703
|
+
for iface_name, iface_stats in sorted(stats.items()):
|
|
704
|
+
# Determine type
|
|
705
|
+
iface_type = _classify_interface(iface_name)
|
|
706
|
+
|
|
707
|
+
# Get IP and MAC addresses
|
|
708
|
+
ip_addr = ""
|
|
709
|
+
mac_addr = ""
|
|
710
|
+
if iface_name in addrs:
|
|
711
|
+
for addr in addrs[iface_name]:
|
|
712
|
+
if addr.family == socket.AF_INET:
|
|
713
|
+
ip_addr = addr.address
|
|
714
|
+
# psutil.AF_LINK = 17 on Linux, 18 on macOS
|
|
715
|
+
if hasattr(psutil, "AF_LINK") and addr.family == psutil.AF_LINK:
|
|
716
|
+
mac_addr = addr.address
|
|
717
|
+
|
|
718
|
+
state = "up" if iface_stats.isup else "down"
|
|
719
|
+
extra = {}
|
|
720
|
+
|
|
721
|
+
if iface_stats.speed > 0 and iface_type == "ethernet":
|
|
722
|
+
extra["speed_mbps"] = iface_stats.speed
|
|
723
|
+
|
|
724
|
+
# WiFi details (Linux)
|
|
725
|
+
if iface_type == "wifi" and state == "up":
|
|
726
|
+
wifi_info = _get_wifi_info(iface_name)
|
|
727
|
+
extra.update(wifi_info)
|
|
728
|
+
|
|
729
|
+
# CAN bitrate (Linux)
|
|
730
|
+
if iface_type == "can" and state == "up":
|
|
731
|
+
bitrate_str = _read_sysfs(
|
|
732
|
+
f"/sys/class/net/{iface_name}/can_bittiming/bitrate"
|
|
733
|
+
)
|
|
734
|
+
if bitrate_str and bitrate_str.isdigit():
|
|
735
|
+
extra["bitrate"] = int(bitrate_str)
|
|
736
|
+
|
|
737
|
+
interfaces.append(NetworkInterface(
|
|
738
|
+
name=iface_name,
|
|
739
|
+
type=iface_type,
|
|
740
|
+
ip=ip_addr,
|
|
741
|
+
mac=mac_addr,
|
|
742
|
+
state=state,
|
|
743
|
+
extra=extra,
|
|
744
|
+
))
|
|
745
|
+
|
|
746
|
+
return interfaces
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _detect_network_linux() -> List[NetworkInterface]:
|
|
750
|
+
"""Detect network interfaces on Linux using `ip addr`."""
|
|
751
|
+
interfaces = []
|
|
752
|
+
try:
|
|
753
|
+
result = subprocess.run(
|
|
754
|
+
["ip", "-o", "addr", "show"],
|
|
755
|
+
capture_output=True, text=True, timeout=5,
|
|
756
|
+
)
|
|
757
|
+
if result.returncode != 0:
|
|
758
|
+
return interfaces
|
|
759
|
+
|
|
760
|
+
seen = {}
|
|
761
|
+
for line in result.stdout.strip().splitlines():
|
|
762
|
+
parts = line.split()
|
|
763
|
+
if len(parts) < 4:
|
|
764
|
+
continue
|
|
765
|
+
iface_name = parts[1]
|
|
766
|
+
# ip -o shows "eth0" or "eth0:" — strip colon
|
|
767
|
+
iface_name = iface_name.rstrip(":")
|
|
768
|
+
|
|
769
|
+
if iface_name not in seen:
|
|
770
|
+
iface_type = _classify_interface(iface_name)
|
|
771
|
+
seen[iface_name] = NetworkInterface(
|
|
772
|
+
name=iface_name,
|
|
773
|
+
type=iface_type,
|
|
774
|
+
state="up",
|
|
775
|
+
extra={},
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
# Parse inet addresses
|
|
779
|
+
if "inet " in line:
|
|
780
|
+
for i, p in enumerate(parts):
|
|
781
|
+
if p == "inet" and i + 1 < len(parts):
|
|
782
|
+
seen[iface_name].ip = parts[i + 1].split("/")[0]
|
|
783
|
+
|
|
784
|
+
# Parse link/ether MAC
|
|
785
|
+
if "link/ether" in line:
|
|
786
|
+
for i, p in enumerate(parts):
|
|
787
|
+
if p == "link/ether" and i + 1 < len(parts):
|
|
788
|
+
seen[iface_name].mac = parts[i + 1]
|
|
789
|
+
|
|
790
|
+
interfaces = list(seen.values())
|
|
791
|
+
|
|
792
|
+
# Enrich WiFi info
|
|
793
|
+
for iface in interfaces:
|
|
794
|
+
if iface.type == "wifi" and iface.state == "up":
|
|
795
|
+
iface.extra.update(_get_wifi_info(iface.name))
|
|
796
|
+
|
|
797
|
+
except FileNotFoundError:
|
|
798
|
+
logger.debug("Network: 'ip' command not found")
|
|
799
|
+
except subprocess.TimeoutExpired:
|
|
800
|
+
logger.debug("Network: 'ip' command timed out")
|
|
801
|
+
except Exception as e:
|
|
802
|
+
logger.debug(f"Network: Linux detection failed: {e}")
|
|
803
|
+
|
|
804
|
+
return interfaces
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _detect_network_macos() -> List[NetworkInterface]:
|
|
808
|
+
"""Detect network interfaces on macOS using `ifconfig`."""
|
|
809
|
+
interfaces = []
|
|
810
|
+
try:
|
|
811
|
+
result = subprocess.run(
|
|
812
|
+
["ifconfig"],
|
|
813
|
+
capture_output=True, text=True, timeout=5,
|
|
814
|
+
)
|
|
815
|
+
if result.returncode != 0:
|
|
816
|
+
return interfaces
|
|
817
|
+
|
|
818
|
+
current_iface = None
|
|
819
|
+
for line in result.stdout.splitlines():
|
|
820
|
+
# Interface header line: "en0: flags=..."
|
|
821
|
+
if not line.startswith("\t") and not line.startswith(" ") and ":" in line:
|
|
822
|
+
iface_name = line.split(":")[0]
|
|
823
|
+
iface_type = _classify_interface(iface_name)
|
|
824
|
+
state = "up" if "UP" in line else "down"
|
|
825
|
+
current_iface = NetworkInterface(
|
|
826
|
+
name=iface_name,
|
|
827
|
+
type=iface_type,
|
|
828
|
+
state=state,
|
|
829
|
+
extra={},
|
|
830
|
+
)
|
|
831
|
+
interfaces.append(current_iface)
|
|
832
|
+
elif current_iface:
|
|
833
|
+
stripped = line.strip()
|
|
834
|
+
if stripped.startswith("inet "):
|
|
835
|
+
parts = stripped.split()
|
|
836
|
+
if len(parts) >= 2:
|
|
837
|
+
current_iface.ip = parts[1]
|
|
838
|
+
elif stripped.startswith("ether "):
|
|
839
|
+
parts = stripped.split()
|
|
840
|
+
if len(parts) >= 2:
|
|
841
|
+
current_iface.mac = parts[1]
|
|
842
|
+
|
|
843
|
+
# Enrich WiFi info on macOS
|
|
844
|
+
for iface in interfaces:
|
|
845
|
+
if iface.type == "wifi" and iface.state == "up":
|
|
846
|
+
iface.extra.update(_get_wifi_info_macos(iface.name))
|
|
847
|
+
|
|
848
|
+
except FileNotFoundError:
|
|
849
|
+
logger.debug("Network: ifconfig not found")
|
|
850
|
+
except subprocess.TimeoutExpired:
|
|
851
|
+
logger.debug("Network: ifconfig timed out")
|
|
852
|
+
except Exception as e:
|
|
853
|
+
logger.debug(f"Network: macOS detection failed: {e}")
|
|
854
|
+
|
|
855
|
+
return interfaces
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def _classify_interface(name: str) -> str:
|
|
859
|
+
"""Classify a network interface by its name."""
|
|
860
|
+
name_lower = name.lower()
|
|
861
|
+
if name_lower.startswith("lo") or name_lower == "lo0":
|
|
862
|
+
return "loopback"
|
|
863
|
+
if name_lower.startswith(("wlan", "wlp", "wlx")):
|
|
864
|
+
return "wifi"
|
|
865
|
+
if name_lower.startswith(("en", "eth", "enp", "eno", "ens")):
|
|
866
|
+
# On macOS en0 is often WiFi — check further
|
|
867
|
+
if platform.system() == "Darwin" and name_lower in ("en0",):
|
|
868
|
+
# en0 is WiFi on most Macs
|
|
869
|
+
return "wifi"
|
|
870
|
+
return "ethernet"
|
|
871
|
+
if name_lower.startswith("can") or name_lower.startswith("vcan"):
|
|
872
|
+
return "can"
|
|
873
|
+
if name_lower.startswith("docker") or name_lower.startswith("br-"):
|
|
874
|
+
return "bridge"
|
|
875
|
+
if name_lower.startswith("veth"):
|
|
876
|
+
return "veth"
|
|
877
|
+
if name_lower.startswith(("awdl", "llw")):
|
|
878
|
+
return "airdrop"
|
|
879
|
+
if name_lower.startswith("utun") or name_lower.startswith("tun"):
|
|
880
|
+
return "tunnel"
|
|
881
|
+
return "other"
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def _get_wifi_info(iface_name: str) -> Dict[str, Any]:
|
|
885
|
+
"""Get WiFi details for an interface on Linux using iwconfig/iw."""
|
|
886
|
+
info = {}
|
|
887
|
+
# Try iw first
|
|
888
|
+
try:
|
|
889
|
+
result = subprocess.run(
|
|
890
|
+
["iw", "dev", iface_name, "link"],
|
|
891
|
+
capture_output=True, text=True, timeout=3,
|
|
892
|
+
)
|
|
893
|
+
if result.returncode == 0:
|
|
894
|
+
for line in result.stdout.splitlines():
|
|
895
|
+
stripped = line.strip()
|
|
896
|
+
if stripped.startswith("SSID:"):
|
|
897
|
+
info["ssid"] = stripped.split(":", 1)[1].strip()
|
|
898
|
+
elif "signal:" in stripped:
|
|
899
|
+
# e.g. "signal: -52 dBm"
|
|
900
|
+
parts = stripped.split()
|
|
901
|
+
for i, p in enumerate(parts):
|
|
902
|
+
if p == "signal:" and i + 1 < len(parts):
|
|
903
|
+
try:
|
|
904
|
+
info["signal_dbm"] = int(parts[i + 1])
|
|
905
|
+
except ValueError:
|
|
906
|
+
pass
|
|
907
|
+
if info:
|
|
908
|
+
return info
|
|
909
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
910
|
+
pass
|
|
911
|
+
except Exception as e:
|
|
912
|
+
logger.debug(f"WiFi: iw failed for {iface_name}: {e}")
|
|
913
|
+
|
|
914
|
+
# Try iwconfig fallback
|
|
915
|
+
try:
|
|
916
|
+
result = subprocess.run(
|
|
917
|
+
["iwconfig", iface_name],
|
|
918
|
+
capture_output=True, text=True, timeout=3,
|
|
919
|
+
)
|
|
920
|
+
if result.returncode == 0:
|
|
921
|
+
for line in result.stdout.splitlines():
|
|
922
|
+
if 'ESSID:"' in line:
|
|
923
|
+
start = line.index('ESSID:"') + 7
|
|
924
|
+
end = line.index('"', start)
|
|
925
|
+
info["ssid"] = line[start:end]
|
|
926
|
+
if "Signal level=" in line:
|
|
927
|
+
part = line.split("Signal level=")[1].split()[0]
|
|
928
|
+
try:
|
|
929
|
+
info["signal_dbm"] = int(part.replace("dBm", ""))
|
|
930
|
+
except ValueError:
|
|
931
|
+
pass
|
|
932
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
933
|
+
pass
|
|
934
|
+
except Exception as e:
|
|
935
|
+
logger.debug(f"WiFi: iwconfig failed for {iface_name}: {e}")
|
|
936
|
+
|
|
937
|
+
return info
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def _get_wifi_info_macos(iface_name: str) -> Dict[str, Any]:
|
|
941
|
+
"""Get WiFi details for an interface on macOS."""
|
|
942
|
+
info = {}
|
|
943
|
+
try:
|
|
944
|
+
# macOS 14.4+: use the airport utility path
|
|
945
|
+
airport_path = (
|
|
946
|
+
"/System/Library/PrivateFrameworks/Apple80211.framework"
|
|
947
|
+
"/Versions/Current/Resources/airport"
|
|
948
|
+
)
|
|
949
|
+
if os.path.exists(airport_path):
|
|
950
|
+
result = subprocess.run(
|
|
951
|
+
[airport_path, "-I"],
|
|
952
|
+
capture_output=True, text=True, timeout=3,
|
|
953
|
+
)
|
|
954
|
+
else:
|
|
955
|
+
# Try networksetup as fallback
|
|
956
|
+
result = subprocess.run(
|
|
957
|
+
["networksetup", "-getairportnetwork", iface_name],
|
|
958
|
+
capture_output=True, text=True, timeout=3,
|
|
959
|
+
)
|
|
960
|
+
if result.returncode == 0 and ":" in result.stdout:
|
|
961
|
+
ssid = result.stdout.split(":", 1)[1].strip()
|
|
962
|
+
if ssid:
|
|
963
|
+
info["ssid"] = ssid
|
|
964
|
+
return info
|
|
965
|
+
|
|
966
|
+
if result.returncode == 0:
|
|
967
|
+
for line in result.stdout.splitlines():
|
|
968
|
+
stripped = line.strip()
|
|
969
|
+
if stripped.startswith("SSID:"):
|
|
970
|
+
info["ssid"] = stripped.split(":", 1)[1].strip()
|
|
971
|
+
elif stripped.startswith("agrCtlRSSI:"):
|
|
972
|
+
try:
|
|
973
|
+
info["signal_dbm"] = int(stripped.split(":", 1)[1].strip())
|
|
974
|
+
except ValueError:
|
|
975
|
+
pass
|
|
976
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
977
|
+
pass
|
|
978
|
+
except Exception as e:
|
|
979
|
+
logger.debug(f"WiFi: macOS detection failed for {iface_name}: {e}")
|
|
980
|
+
|
|
981
|
+
return info
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
985
|
+
# System Info Detection
|
|
986
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
987
|
+
|
|
988
|
+
def detect_system() -> SystemInfo:
|
|
989
|
+
"""Get comprehensive system information.
|
|
990
|
+
|
|
991
|
+
Uses stdlib modules (platform, os, shutil, socket) for broad compatibility.
|
|
992
|
+
|
|
993
|
+
Returns:
|
|
994
|
+
SystemInfo dataclass. Fields may be empty strings / zero if unavailable.
|
|
995
|
+
"""
|
|
996
|
+
info = SystemInfo()
|
|
997
|
+
|
|
998
|
+
try:
|
|
999
|
+
info.hostname = socket.gethostname()
|
|
1000
|
+
except Exception:
|
|
1001
|
+
info.hostname = "unknown"
|
|
1002
|
+
|
|
1003
|
+
info.platform = platform.system()
|
|
1004
|
+
info.arch = platform.machine()
|
|
1005
|
+
info.python_version = platform.python_version()
|
|
1006
|
+
|
|
1007
|
+
# CPU info
|
|
1008
|
+
info.cpu = _get_cpu_info()
|
|
1009
|
+
try:
|
|
1010
|
+
info.cpu_cores = os.cpu_count() or 0
|
|
1011
|
+
except Exception:
|
|
1012
|
+
info.cpu_cores = 0
|
|
1013
|
+
|
|
1014
|
+
# RAM
|
|
1015
|
+
info.ram_mb = _get_ram_mb()
|
|
1016
|
+
|
|
1017
|
+
# Disk (available space on root partition)
|
|
1018
|
+
try:
|
|
1019
|
+
usage = shutil.disk_usage("/")
|
|
1020
|
+
info.disk_gb = round(usage.free / (1024 ** 3), 1)
|
|
1021
|
+
except Exception:
|
|
1022
|
+
info.disk_gb = 0.0
|
|
1023
|
+
|
|
1024
|
+
# OS version
|
|
1025
|
+
info.os_version = _get_os_version()
|
|
1026
|
+
|
|
1027
|
+
return info
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def _get_cpu_info() -> str:
|
|
1031
|
+
"""Get a human-readable CPU description."""
|
|
1032
|
+
system = platform.system()
|
|
1033
|
+
|
|
1034
|
+
# Linux: /proc/cpuinfo
|
|
1035
|
+
if system == "Linux":
|
|
1036
|
+
try:
|
|
1037
|
+
with open("/proc/cpuinfo", "r") as f:
|
|
1038
|
+
for line in f:
|
|
1039
|
+
if line.startswith("model name"):
|
|
1040
|
+
return line.split(":", 1)[1].strip()
|
|
1041
|
+
# ARM chips often use "Hardware" or "Model"
|
|
1042
|
+
if line.startswith("Hardware"):
|
|
1043
|
+
return line.split(":", 1)[1].strip()
|
|
1044
|
+
except (OSError, IOError):
|
|
1045
|
+
pass
|
|
1046
|
+
|
|
1047
|
+
# ARM fallback: check device-tree
|
|
1048
|
+
model = _read_sysfs("/proc/device-tree/model")
|
|
1049
|
+
if model:
|
|
1050
|
+
return model
|
|
1051
|
+
|
|
1052
|
+
# macOS: sysctl
|
|
1053
|
+
if system == "Darwin":
|
|
1054
|
+
try:
|
|
1055
|
+
result = subprocess.run(
|
|
1056
|
+
["sysctl", "-n", "machdep.cpu.brand_string"],
|
|
1057
|
+
capture_output=True, text=True, timeout=3,
|
|
1058
|
+
)
|
|
1059
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
1060
|
+
return result.stdout.strip()
|
|
1061
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
1062
|
+
pass
|
|
1063
|
+
|
|
1064
|
+
return platform.processor() or platform.machine()
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def _get_ram_mb() -> int:
|
|
1068
|
+
"""Get total RAM in megabytes."""
|
|
1069
|
+
# Method 1: psutil
|
|
1070
|
+
try:
|
|
1071
|
+
import psutil
|
|
1072
|
+
return round(psutil.virtual_memory().total / (1024 * 1024))
|
|
1073
|
+
except ImportError:
|
|
1074
|
+
pass
|
|
1075
|
+
|
|
1076
|
+
# Method 2: Linux /proc/meminfo
|
|
1077
|
+
if platform.system() == "Linux":
|
|
1078
|
+
try:
|
|
1079
|
+
with open("/proc/meminfo", "r") as f:
|
|
1080
|
+
for line in f:
|
|
1081
|
+
if line.startswith("MemTotal:"):
|
|
1082
|
+
# Value is in kB
|
|
1083
|
+
kb = int(line.split()[1])
|
|
1084
|
+
return round(kb / 1024)
|
|
1085
|
+
except (OSError, IOError, ValueError):
|
|
1086
|
+
pass
|
|
1087
|
+
|
|
1088
|
+
# Method 3: macOS sysctl
|
|
1089
|
+
if platform.system() == "Darwin":
|
|
1090
|
+
try:
|
|
1091
|
+
result = subprocess.run(
|
|
1092
|
+
["sysctl", "-n", "hw.memsize"],
|
|
1093
|
+
capture_output=True, text=True, timeout=3,
|
|
1094
|
+
)
|
|
1095
|
+
if result.returncode == 0:
|
|
1096
|
+
return round(int(result.stdout.strip()) / (1024 * 1024))
|
|
1097
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, ValueError):
|
|
1098
|
+
pass
|
|
1099
|
+
|
|
1100
|
+
return 0
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
def _get_os_version() -> str:
|
|
1104
|
+
"""Get a human-readable OS version string."""
|
|
1105
|
+
system = platform.system()
|
|
1106
|
+
|
|
1107
|
+
if system == "Linux":
|
|
1108
|
+
# Try /etc/os-release
|
|
1109
|
+
try:
|
|
1110
|
+
with open("/etc/os-release", "r") as f:
|
|
1111
|
+
info = {}
|
|
1112
|
+
for line in f:
|
|
1113
|
+
if "=" in line:
|
|
1114
|
+
key, val = line.strip().split("=", 1)
|
|
1115
|
+
info[key] = val.strip('"')
|
|
1116
|
+
pretty = info.get("PRETTY_NAME")
|
|
1117
|
+
if pretty:
|
|
1118
|
+
return pretty
|
|
1119
|
+
except (OSError, IOError):
|
|
1120
|
+
pass
|
|
1121
|
+
|
|
1122
|
+
return f"Linux {platform.release()}"
|
|
1123
|
+
|
|
1124
|
+
if system == "Darwin":
|
|
1125
|
+
mac_ver = platform.mac_ver()[0]
|
|
1126
|
+
if mac_ver:
|
|
1127
|
+
return f"macOS {mac_ver}"
|
|
1128
|
+
return "macOS"
|
|
1129
|
+
|
|
1130
|
+
return platform.platform()
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1134
|
+
# Full Scan (aggregate all detectors)
|
|
1135
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
1136
|
+
|
|
1137
|
+
def detect_all(bus: Optional[int] = None) -> Dict[str, Any]:
|
|
1138
|
+
"""Run all detection functions and return a combined dictionary.
|
|
1139
|
+
|
|
1140
|
+
This is used by `plexus scan --json` and for the comprehensive scan output.
|
|
1141
|
+
|
|
1142
|
+
Args:
|
|
1143
|
+
bus: I2C bus number for sensor scanning.
|
|
1144
|
+
|
|
1145
|
+
Returns:
|
|
1146
|
+
Dictionary with keys for each hardware category.
|
|
1147
|
+
"""
|
|
1148
|
+
# System info
|
|
1149
|
+
sys_info = detect_system()
|
|
1150
|
+
|
|
1151
|
+
# I2C sensors
|
|
1152
|
+
_, sensors = detect_sensors(bus)
|
|
1153
|
+
|
|
1154
|
+
# Cameras
|
|
1155
|
+
_, cameras = detect_cameras()
|
|
1156
|
+
|
|
1157
|
+
# Serial ports
|
|
1158
|
+
serial_devices = detect_serial()
|
|
1159
|
+
|
|
1160
|
+
# USB devices
|
|
1161
|
+
usb_devices = detect_usb()
|
|
1162
|
+
|
|
1163
|
+
# Network interfaces
|
|
1164
|
+
network_interfaces = detect_network()
|
|
1165
|
+
|
|
1166
|
+
# GPIO
|
|
1167
|
+
gpio_chips = detect_gpio()
|
|
1168
|
+
|
|
1169
|
+
# Bluetooth
|
|
1170
|
+
bt_devices = detect_bluetooth()
|
|
1171
|
+
|
|
1172
|
+
# CAN
|
|
1173
|
+
_, can_up, can_down = detect_can()
|
|
1174
|
+
|
|
1175
|
+
# MAVLink
|
|
1176
|
+
mavlink_connections = detect_mavlink()
|
|
1177
|
+
|
|
1178
|
+
return {
|
|
1179
|
+
"system": asdict(sys_info),
|
|
1180
|
+
"sensors": [
|
|
1181
|
+
{
|
|
1182
|
+
"name": s.name,
|
|
1183
|
+
"address": f"0x{s.address:02X}",
|
|
1184
|
+
"bus": s.bus,
|
|
1185
|
+
"description": s.description,
|
|
1186
|
+
}
|
|
1187
|
+
for s in sensors
|
|
1188
|
+
],
|
|
1189
|
+
"cameras": [
|
|
1190
|
+
{
|
|
1191
|
+
"name": c.name,
|
|
1192
|
+
"device_id": c.device_id,
|
|
1193
|
+
"description": c.description,
|
|
1194
|
+
}
|
|
1195
|
+
for c in cameras
|
|
1196
|
+
],
|
|
1197
|
+
"serial": [asdict(d) for d in serial_devices],
|
|
1198
|
+
"usb": [asdict(d) for d in usb_devices],
|
|
1199
|
+
"network": [
|
|
1200
|
+
{
|
|
1201
|
+
"name": n.name,
|
|
1202
|
+
"type": n.type,
|
|
1203
|
+
"ip": n.ip,
|
|
1204
|
+
"mac": n.mac,
|
|
1205
|
+
"state": n.state,
|
|
1206
|
+
"extra": n.extra,
|
|
1207
|
+
}
|
|
1208
|
+
for n in network_interfaces
|
|
1209
|
+
],
|
|
1210
|
+
"gpio": [asdict(g) for g in gpio_chips],
|
|
1211
|
+
"bluetooth": [asdict(b) for b in bt_devices],
|
|
1212
|
+
"can": {
|
|
1213
|
+
"up": [
|
|
1214
|
+
{
|
|
1215
|
+
"interface": c.interface,
|
|
1216
|
+
"channel": c.channel,
|
|
1217
|
+
"bitrate": c.bitrate,
|
|
1218
|
+
}
|
|
1219
|
+
for c in can_up
|
|
1220
|
+
],
|
|
1221
|
+
"down": [
|
|
1222
|
+
{
|
|
1223
|
+
"interface": c.interface,
|
|
1224
|
+
"channel": c.channel,
|
|
1225
|
+
}
|
|
1226
|
+
for c in can_down
|
|
1227
|
+
],
|
|
1228
|
+
},
|
|
1229
|
+
"mavlink": [
|
|
1230
|
+
{
|
|
1231
|
+
"connection_string": m.connection_string,
|
|
1232
|
+
"transport": m.transport,
|
|
1233
|
+
"description": m.description,
|
|
1234
|
+
"is_available": m.is_available,
|
|
1235
|
+
}
|
|
1236
|
+
for m in mavlink_connections
|
|
1237
|
+
],
|
|
1238
|
+
}
|