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.
Files changed (50) hide show
  1. plexus/__init__.py +31 -0
  2. plexus/__main__.py +4 -0
  3. plexus/adapters/__init__.py +122 -0
  4. plexus/adapters/base.py +409 -0
  5. plexus/adapters/ble.py +257 -0
  6. plexus/adapters/can.py +439 -0
  7. plexus/adapters/can_detect.py +174 -0
  8. plexus/adapters/mavlink.py +642 -0
  9. plexus/adapters/mavlink_detect.py +192 -0
  10. plexus/adapters/modbus.py +622 -0
  11. plexus/adapters/mqtt.py +350 -0
  12. plexus/adapters/opcua.py +607 -0
  13. plexus/adapters/registry.py +206 -0
  14. plexus/adapters/serial_adapter.py +547 -0
  15. plexus/buffer.py +257 -0
  16. plexus/cameras/__init__.py +57 -0
  17. plexus/cameras/auto.py +239 -0
  18. plexus/cameras/base.py +189 -0
  19. plexus/cameras/picamera.py +171 -0
  20. plexus/cameras/usb.py +143 -0
  21. plexus/cli.py +783 -0
  22. plexus/client.py +465 -0
  23. plexus/config.py +169 -0
  24. plexus/connector.py +666 -0
  25. plexus/deps.py +246 -0
  26. plexus/detect.py +1238 -0
  27. plexus/importers/__init__.py +25 -0
  28. plexus/importers/rosbag.py +778 -0
  29. plexus/sensors/__init__.py +118 -0
  30. plexus/sensors/ads1115.py +164 -0
  31. plexus/sensors/adxl345.py +179 -0
  32. plexus/sensors/auto.py +290 -0
  33. plexus/sensors/base.py +412 -0
  34. plexus/sensors/bh1750.py +102 -0
  35. plexus/sensors/bme280.py +241 -0
  36. plexus/sensors/gps.py +317 -0
  37. plexus/sensors/ina219.py +149 -0
  38. plexus/sensors/magnetometer.py +239 -0
  39. plexus/sensors/mpu6050.py +162 -0
  40. plexus/sensors/sht3x.py +139 -0
  41. plexus/sensors/spi_scan.py +164 -0
  42. plexus/sensors/system.py +261 -0
  43. plexus/sensors/vl53l0x.py +109 -0
  44. plexus/streaming.py +743 -0
  45. plexus/tui.py +642 -0
  46. plexus_python-0.1.0.dist-info/METADATA +470 -0
  47. plexus_python-0.1.0.dist-info/RECORD +50 -0
  48. plexus_python-0.1.0.dist-info/WHEEL +4 -0
  49. plexus_python-0.1.0.dist-info/entry_points.txt +2 -0
  50. 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
+ }