remoterf-device-core 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. remoterf_device_core-0.1.0/PKG-INFO +28 -0
  2. remoterf_device_core-0.1.0/README.md +13 -0
  3. remoterf_device_core-0.1.0/pyproject.toml +29 -0
  4. remoterf_device_core-0.1.0/setup.cfg +4 -0
  5. remoterf_device_core-0.1.0/src/remoterf_device_core/__init__.py +14 -0
  6. remoterf_device_core-0.1.0/src/remoterf_device_core/digest.py +23 -0
  7. remoterf_device_core-0.1.0/src/remoterf_device_core/direct.py +180 -0
  8. remoterf_device_core-0.1.0/src/remoterf_device_core/drivers/__init__.py +14 -0
  9. remoterf_device_core-0.1.0/src/remoterf_device_core/drivers/adalm_pluto/__init__.py +14 -0
  10. remoterf_device_core-0.1.0/src/remoterf_device_core/drivers/adalm_pluto/pluto_mimo_schema.py +231 -0
  11. remoterf_device_core-0.1.0/src/remoterf_device_core/drivers/adalm_pluto/pluto_schema.py +445 -0
  12. remoterf_device_core-0.1.0/src/remoterf_device_core/drivers/adalm_pluto/poe.py +255 -0
  13. remoterf_device_core-0.1.0/src/remoterf_device_core/drivers/hackrf/__init__.py +14 -0
  14. remoterf_device_core-0.1.0/src/remoterf_device_core/drivers/hackrf/hackrf_schema.py +448 -0
  15. remoterf_device_core-0.1.0/src/remoterf_device_core/drivers/rtl_sdr/__init__.py +14 -0
  16. remoterf_device_core-0.1.0/src/remoterf_device_core/drivers/rtl_sdr/rtl_sdr_schema.py +351 -0
  17. remoterf_device_core-0.1.0/src/remoterf_device_core/drivers/ti_mmwave/__init__.py +14 -0
  18. remoterf_device_core-0.1.0/src/remoterf_device_core/drivers/ti_mmwave/ti_mmwave_schema.py +667 -0
  19. remoterf_device_core-0.1.0/src/remoterf_device_core/drivers/usrp/__init__.py +14 -0
  20. remoterf_device_core-0.1.0/src/remoterf_device_core/drivers/usrp/policy.py +56 -0
  21. remoterf_device_core-0.1.0/src/remoterf_device_core/drivers/usrp/profiles.py +366 -0
  22. remoterf_device_core-0.1.0/src/remoterf_device_core/drivers/usrp/uhd_manifest.py +1666 -0
  23. remoterf_device_core-0.1.0/src/remoterf_device_core/drivers/usrp/usrp_schema.py +1174 -0
  24. remoterf_device_core-0.1.0/src/remoterf_device_core/drivers/usrp_phaser/__init__.py +14 -0
  25. remoterf_device_core-0.1.0/src/remoterf_device_core/drivers/usrp_phaser/usrp_phaser_schema.py +556 -0
  26. remoterf_device_core-0.1.0/src/remoterf_device_core/grpc/__init__.py +14 -0
  27. remoterf_device_core-0.1.0/src/remoterf_device_core/grpc/grpc.proto +237 -0
  28. remoterf_device_core-0.1.0/src/remoterf_device_core/grpc/grpc_host.proto +132 -0
  29. remoterf_device_core-0.1.0/src/remoterf_device_core/grpc/grpc_host_pb2.py +80 -0
  30. remoterf_device_core-0.1.0/src/remoterf_device_core/grpc/grpc_host_pb2_grpc.py +112 -0
  31. remoterf_device_core-0.1.0/src/remoterf_device_core/grpc/grpc_pb2.py +116 -0
  32. remoterf_device_core-0.1.0/src/remoterf_device_core/grpc/grpc_pb2_grpc.py +477 -0
  33. remoterf_device_core-0.1.0/src/remoterf_device_core/grpc/v2_codec.py +146 -0
  34. remoterf_device_core-0.1.0/src/remoterf_device_core/idl/__init__.py +46 -0
  35. remoterf_device_core-0.1.0/src/remoterf_device_core/idl/schema.py +1216 -0
  36. remoterf_device_core-0.1.0/src/remoterf_device_core/idl/v2.py +311 -0
  37. remoterf_device_core-0.1.0/src/remoterf_device_core/manifest.py +82 -0
  38. remoterf_device_core-0.1.0/src/remoterf_device_core/schemas.json +44 -0
  39. remoterf_device_core-0.1.0/src/remoterf_device_core/utils/__init__.py +14 -0
  40. remoterf_device_core-0.1.0/src/remoterf_device_core/utils/ansi_codes.py +135 -0
  41. remoterf_device_core-0.1.0/src/remoterf_device_core/utils/config.py +22 -0
  42. remoterf_device_core-0.1.0/src/remoterf_device_core/utils/process_arg.py +246 -0
  43. remoterf_device_core-0.1.0/src/remoterf_device_core/v2_errors.py +114 -0
  44. remoterf_device_core-0.1.0/src/remoterf_device_core.egg-info/PKG-INFO +28 -0
  45. remoterf_device_core-0.1.0/src/remoterf_device_core.egg-info/SOURCES.txt +47 -0
  46. remoterf_device_core-0.1.0/src/remoterf_device_core.egg-info/dependency_links.txt +1 -0
  47. remoterf_device_core-0.1.0/src/remoterf_device_core.egg-info/requires.txt +8 -0
  48. remoterf_device_core-0.1.0/src/remoterf_device_core.egg-info/top_level.txt +1 -0
  49. remoterf_device_core-0.1.0/tests/test_manifest.py +58 -0
@@ -0,0 +1,28 @@
1
+ Metadata-Version: 2.4
2
+ Name: remoterf-device-core
3
+ Version: 0.1.0
4
+ Summary: RemoteRF device protocol, IDL and built-in driver schemas shared by the lab Server and Host
5
+ License: GPL-3.0-or-later
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: grpcio<2.0.0,>=1.78.1
9
+ Requires-Dist: protobuf<7.0.0,>=6.31.1
10
+ Requires-Dist: numpy
11
+ Requires-Dist: prompt_toolkit
12
+ Provides-Extra: direct
13
+ Requires-Dist: aioice<0.11,>=0.10; extra == "direct"
14
+ Requires-Dist: aioquic<2.0,>=1.2; extra == "direct"
15
+
16
+ # remoterf-device-core
17
+
18
+ The parts of RemoteRF that a lab Server (`remoteRF-server`) and a Host (`remoterf-host`) share, kept as one copy:
19
+
20
+ - the gRPC protocol (`grpc.proto`, `grpc_host.proto`) and the Dynamic v2 codec;
21
+ - the device IDL (`DeviceSchema`, `idl_expose`, `idl_register`) and the schema v2 builder;
22
+ - the built-in driver schemas: ADALM-Pluto (incl. PoE), Pluto MIMO, HackRF, RTL-SDR, TI mmWave, USRP and USRP Phaser;
23
+ - `schemas.json`, the built-in drivers' schema hashes, and `schema_digest()`, the one hash rule;
24
+ - the ICE/QUIC helpers for direct device paths (install with the `direct` extra).
25
+
26
+ Server and Host pin the same version, so a host running a built-in driver reports exactly the hash its lab knows.
27
+
28
+ License: GPL-3.0-or-later.
@@ -0,0 +1,13 @@
1
+ # remoterf-device-core
2
+
3
+ The parts of RemoteRF that a lab Server (`remoteRF-server`) and a Host (`remoterf-host`) share, kept as one copy:
4
+
5
+ - the gRPC protocol (`grpc.proto`, `grpc_host.proto`) and the Dynamic v2 codec;
6
+ - the device IDL (`DeviceSchema`, `idl_expose`, `idl_register`) and the schema v2 builder;
7
+ - the built-in driver schemas: ADALM-Pluto (incl. PoE), Pluto MIMO, HackRF, RTL-SDR, TI mmWave, USRP and USRP Phaser;
8
+ - `schemas.json`, the built-in drivers' schema hashes, and `schema_digest()`, the one hash rule;
9
+ - the ICE/QUIC helpers for direct device paths (install with the `direct` extra).
10
+
11
+ Server and Host pin the same version, so a host running a built-in driver reports exactly the hash its lab knows.
12
+
13
+ License: GPL-3.0-or-later.
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "remoterf-device-core"
7
+ version = "0.1.0"
8
+ description = "RemoteRF device protocol, IDL and built-in driver schemas shared by the lab Server and Host"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "GPL-3.0-or-later"}
12
+ dependencies = [
13
+ "grpcio>=1.78.1,<2.0.0",
14
+ "protobuf>=6.31.1,<7.0.0",
15
+ "numpy",
16
+ "prompt_toolkit",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ direct = [
21
+ "aioice>=0.10,<0.11",
22
+ "aioquic>=1.2,<2.0",
23
+ ]
24
+
25
+ [tool.setuptools.packages.find]
26
+ where = ["src"]
27
+
28
+ [tool.setuptools.package-data]
29
+ remoterf_device_core = ["schemas.json", "grpc/*.proto"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,14 @@
1
+ # Copyright (C) 2026 RemoteRF
2
+ #
3
+ # This program is free software: you can redistribute it and/or modify
4
+ # it under the terms of the GNU General Public License as published by
5
+ # the Free Software Foundation, either version 3 of the License, or
6
+ # (at your option) any later version.
7
+ #
8
+ # This program is distributed in the hope that it will be useful,
9
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
10
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
+ # GNU General Public License for more details.
12
+ #
13
+ # You should have received a copy of the GNU General Public License
14
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
@@ -0,0 +1,23 @@
1
+ # Copyright (C) 2026 RemoteRF
2
+ #
3
+ # This program is free software: you can redistribute it and/or modify
4
+ # it under the terms of the GNU General Public License as published by
5
+ # the Free Software Foundation, either version 3 of the License, or
6
+ # (at your option) any later version.
7
+ #
8
+ # This program is distributed in the hope that it will be useful,
9
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
10
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
+ # GNU General Public License for more details.
12
+ #
13
+ # You should have received a copy of the GNU General Public License
14
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
15
+
16
+ import hashlib
17
+ import json
18
+
19
+
20
+ def schema_digest(body: dict) -> str:
21
+ """The one schema hash rule: sha256 of the canonical JSON body (no schema_hash in it)."""
22
+ canon = json.dumps(body, sort_keys=True, separators=(",", ":"))
23
+ return f"sha256:{hashlib.sha256(canon.encode('utf-8')).hexdigest()}"
@@ -0,0 +1,180 @@
1
+ # Copyright (C) 2026 RemoteRF
2
+ #
3
+ # This program is free software: you can redistribute it and/or modify
4
+ # it under the terms of the GNU General Public License as published by
5
+ # the Free Software Foundation, either version 3 of the License, or
6
+ # (at your option) any later version.
7
+ #
8
+ # This program is distributed in the hope that it will be useful,
9
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
10
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
+ # GNU General Public License for more details.
12
+ #
13
+ # You should have received a copy of the GNU General Public License
14
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
15
+
16
+ """Shared ICE and QUIC primitives for RemoteRF direct paths."""
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import collections
22
+ import ipaddress
23
+ import os
24
+ import socket
25
+ from typing import Optional
26
+
27
+ from aioice import Connection
28
+ from aioquic.asyncio.protocol import QuicConnectionProtocol
29
+ from aioquic.quic import events
30
+
31
+ ALPN = "remoterf-direct/1"
32
+ MAX_FRAME = 100 * 1024 * 1024
33
+ DEFAULT_STUN = "stun.l.google.com:19302"
34
+ STUN_SECONDS = 1.5
35
+
36
+
37
+ async def close_ice(ice) -> None:
38
+ """Close an aioice Connection without its after-close retry noise."""
39
+ checks = [pair.task for pair in list(getattr(ice, "_check_list", [])) if pair.task is not None]
40
+ for task in checks:
41
+ task.cancel()
42
+ if checks:
43
+ await asyncio.gather(*checks, return_exceptions=True)
44
+ for protocol in list(getattr(ice, "_protocols", [])):
45
+ for transaction in list(getattr(protocol, "transactions", {}).values()):
46
+ handle = getattr(transaction, "_Transaction__timeout_handle", None)
47
+ if handle is not None:
48
+ handle.cancel()
49
+ await ice.close()
50
+
51
+
52
+ async def cancel_tasks(tasks) -> None:
53
+ """Cancel these tasks and wait for them to finish."""
54
+ current = asyncio.current_task()
55
+ tasks = [task for task in tasks if task is not None and task is not current and not task.done()]
56
+ for task in tasks:
57
+ task.cancel()
58
+ if tasks:
59
+ await asyncio.gather(*tasks, return_exceptions=True)
60
+
61
+
62
+ def quiet_aioice_errors(loop) -> None:
63
+ """Suppress expected aioice retry errors after a socket is closed."""
64
+ default = loop.default_exception_handler
65
+
66
+ def handler(loop, context):
67
+ exc = context.get("exception")
68
+ where = repr(context.get("handle", "")) + str(context.get("message", ""))
69
+ if isinstance(exc, (AttributeError, OSError)) and "Transaction.__retry" in where:
70
+ return
71
+ default(context)
72
+
73
+ loop.set_exception_handler(handler)
74
+
75
+
76
+ def frame(payload: bytes) -> bytes:
77
+ return len(payload).to_bytes(4, "big") + payload
78
+
79
+
80
+ async def read_frame(reader: asyncio.StreamReader) -> bytes:
81
+ size = int.from_bytes(await reader.readexactly(4), "big")
82
+ if size > MAX_FRAME:
83
+ raise ValueError(f"frame of {size} bytes exceeds {MAX_FRAME}")
84
+ return await reader.readexactly(size)
85
+
86
+
87
+ def default_route_address() -> Optional[str]:
88
+ """The IPv4 address the default route leaves from, or None without one."""
89
+ try:
90
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:
91
+ probe.connect(("8.8.8.8", 80))
92
+ return probe.getsockname()[0]
93
+ except OSError:
94
+ return None
95
+
96
+
97
+ def stun_server(address: Optional[str] = None) -> Optional[tuple[str, int]]:
98
+ """The configured STUN endpoint, unless the server is publicly addressed."""
99
+ value = os.getenv("REMOTERF_STUN_SERVER", DEFAULT_STUN).strip()
100
+ if value.lower() in {"", "off", "none", "0"}:
101
+ return None
102
+ if address is not None and ipaddress.ip_address(address).is_global:
103
+ return None
104
+ host, _, port = value.rpartition(":")
105
+ return host, int(port)
106
+
107
+
108
+ class Ice(Connection):
109
+ """aioice Connection restricted to the default-route interface."""
110
+
111
+ def __init__(self, *args, **kwargs):
112
+ super().__init__(*args, **kwargs)
113
+ self.heard = asyncio.Event()
114
+
115
+ async def gather_candidates(self) -> None:
116
+ address = default_route_address()
117
+ if address is None or self._local_candidates_start:
118
+ return await super().gather_candidates()
119
+ self._local_candidates_start = True
120
+ self._local_candidates += await self.get_component_candidates(
121
+ component=1, addresses=[address], timeout=STUN_SECONDS
122
+ )
123
+ self._local_candidates_end = True
124
+
125
+ def request_received(self, *args) -> None:
126
+ super().request_received(*args)
127
+ self.heard.set()
128
+
129
+
130
+ def peer_address(ice: Connection) -> tuple[str, int]:
131
+ """The remote end of aioice's nominated pair."""
132
+ return ice._nominated[1].remote_addr
133
+
134
+
135
+ class _IceTransport:
136
+ """The sendto() surface aioquic's protocol needs on a nominated pair."""
137
+
138
+ def __init__(self, ice: Connection):
139
+ self._ice = ice
140
+ self._loop = asyncio.get_running_loop()
141
+ self._queue: collections.deque[bytes] = collections.deque()
142
+ self._pump: Optional[asyncio.Task] = None
143
+ self._closed = False
144
+
145
+ def sendto(self, data: bytes, addr=None) -> None:
146
+ if self._closed:
147
+ return
148
+ self._queue.append(data)
149
+ if self._pump is None:
150
+ self._pump = self._loop.create_task(self._drain())
151
+
152
+ def close(self) -> None:
153
+ self._closed = True
154
+ self._queue.clear()
155
+
156
+ async def _drain(self) -> None:
157
+ try:
158
+ while self._queue:
159
+ await self._ice.sendto(self._queue.popleft(), 1)
160
+ except ConnectionError:
161
+ self._queue.clear()
162
+ finally:
163
+ self._pump = None
164
+
165
+
166
+ class _Protocol(QuicConnectionProtocol):
167
+ """aioquic protocol which remembers why the connection ended."""
168
+
169
+ terminated = ""
170
+
171
+ def quic_event_received(self, event) -> None:
172
+ if isinstance(event, events.ConnectionTerminated):
173
+ self.terminated = event.reason_phrase or (
174
+ f"QUIC error {event.error_code:#x}" if event.error_code else ""
175
+ )
176
+ super().quic_event_received(event)
177
+
178
+
179
+ IceTransport = _IceTransport
180
+ Protocol = _Protocol
@@ -0,0 +1,14 @@
1
+ # Copyright (C) 2026 RemoteRF
2
+ #
3
+ # This program is free software: you can redistribute it and/or modify
4
+ # it under the terms of the GNU General Public License as published by
5
+ # the Free Software Foundation, either version 3 of the License, or
6
+ # (at your option) any later version.
7
+ #
8
+ # This program is distributed in the hope that it will be useful,
9
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
10
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
+ # GNU General Public License for more details.
12
+ #
13
+ # You should have received a copy of the GNU General Public License
14
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
@@ -0,0 +1,14 @@
1
+ # Copyright (C) 2026 RemoteRF
2
+ #
3
+ # This program is free software: you can redistribute it and/or modify
4
+ # it under the terms of the GNU General Public License as published by
5
+ # the Free Software Foundation, either version 3 of the License, or
6
+ # (at your option) any later version.
7
+ #
8
+ # This program is distributed in the hope that it will be useful,
9
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
10
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
+ # GNU General Public License for more details.
12
+ #
13
+ # You should have received a copy of the GNU General Public License
14
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
@@ -0,0 +1,231 @@
1
+ # Copyright (C) 2026 RemoteRF
2
+ #
3
+ # This program is free software: you can redistribute it and/or modify
4
+ # it under the terms of the GNU General Public License as published by
5
+ # the Free Software Foundation, either version 3 of the License, or
6
+ # (at your option) any later version.
7
+ #
8
+ # This program is distributed in the hope that it will be useful,
9
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
10
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
+ # GNU General Public License for more details.
12
+ #
13
+ # You should have received a copy of the GNU General Public License
14
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
15
+
16
+ # SAMPLE schema for ADI Pluto SDR in AD9361 MIMO / 2R2T mode.
17
+ # Place into -> ~/.config/remoterf/drivers and restart server to utilize!
18
+
19
+ from remoterf_device_core.idl import idl_register, idl_expose
20
+ from remoterf_device_core.drivers.adalm_pluto.pluto_schema import PlutoSchema
21
+
22
+ import subprocess
23
+ import re
24
+ from pathlib import Path
25
+
26
+ import adi
27
+
28
+
29
+ def _normalize_channels(value, default):
30
+ if value is None:
31
+ return list(default)
32
+ if isinstance(value, str):
33
+ if not value.strip():
34
+ return list(default)
35
+ return [int(part.strip()) for part in value.split(",") if part.strip()]
36
+ return [int(ch) for ch in value]
37
+
38
+
39
+ def _get_attr(device, *names):
40
+ for name in names:
41
+ if hasattr(device, name):
42
+ return getattr(device, name)
43
+ raise AttributeError(f"{device!r} has none of: {', '.join(names)}")
44
+
45
+
46
+ def _set_attr(device, value, *names):
47
+ for name in names:
48
+ if hasattr(device, name):
49
+ setattr(device, name, value)
50
+ return
51
+ raise AttributeError(f"{device!r} has none of: {', '.join(names)}")
52
+
53
+
54
+ def _apply_initial_settings(device, settings):
55
+ int_attrs = (
56
+ "sample_rate",
57
+ "rx_lo",
58
+ "tx_lo",
59
+ "rx_rf_bandwidth",
60
+ "tx_rf_bandwidth",
61
+ "rx_buffer_size",
62
+ )
63
+ for attr in int_attrs:
64
+ value = settings.get(attr)
65
+ if value is not None:
66
+ setattr(device, attr, int(value))
67
+
68
+
69
+ # == PLUTO MIMO Connection Logic ==
70
+
71
+ def _usb_address_for_port(port):
72
+ """The libiio usb address (bus.address.interface) now behind a physical
73
+ USB port path such as "1-2.3" (Linux sysfs)."""
74
+ base = Path("/sys/bus/usb/devices") / port
75
+ try:
76
+ prefix = f"{int((base / 'busnum').read_text())}.{int((base / 'devnum').read_text())}."
77
+ except (OSError, ValueError):
78
+ return ""
79
+ out = subprocess.check_output(["iio_info", "-s"], text=True, stderr=subprocess.STDOUT)
80
+ for address in re.findall(r"\[usb:([^\]]+)\]", out):
81
+ if address.startswith(prefix):
82
+ return address.strip()
83
+ return ""
84
+
85
+
86
+ def connect_pluto_mimo(
87
+ *,
88
+ serial="",
89
+ usb="",
90
+ usb_port="",
91
+ rx_enabled_channels=None,
92
+ tx_enabled_channels=None,
93
+ initial_settings=None,
94
+ ):
95
+ serial = (serial or "").strip()
96
+ usb = (usb or "").strip()
97
+ usb_port = (usb_port or "").strip()
98
+ if not serial and not usb and not usb_port:
99
+ print("An adalm_pluto_mimo serial, usb bus address or usb_port must be provided")
100
+ return None
101
+
102
+ try:
103
+ if usb_port:
104
+ # A board without a serial keeps its port across re-enumeration;
105
+ # its bus address does not.
106
+ usb = _usb_address_for_port(usb_port)
107
+ if not usb:
108
+ print(f"No adalm_pluto_mimo device found on USB port {usb_port}")
109
+ return None
110
+
111
+ if not usb:
112
+ out = subprocess.check_output(
113
+ ["iio_info", "-s"],
114
+ text=True,
115
+ stderr=subprocess.STDOUT,
116
+ )
117
+
118
+ for line in out.splitlines():
119
+ if (f"serial={serial}" in line) or (f"hw_serial={serial}" in line):
120
+ m = re.search(r"\[usb:([^\]]+)\]", line)
121
+ if m:
122
+ usb = m.group(1).strip()
123
+ break
124
+
125
+ if not usb:
126
+ print(f"No adalm_pluto_mimo device found with serial {serial}")
127
+ return None
128
+
129
+ dev = adi.ad9361(f"usb:{usb}")
130
+ dev.rx_enabled_channels = _normalize_channels(rx_enabled_channels, [0, 1])
131
+ dev.tx_enabled_channels = _normalize_channels(tx_enabled_channels, [0, 1])
132
+ _apply_initial_settings(dev, initial_settings or {})
133
+ from remoterf_device_core.utils.ansi_codes import Sty, printf
134
+
135
+ printf(f"Connected to adalm_pluto_mimo serial={serial} via usb:{usb}", Sty.GRAY)
136
+ return dev
137
+
138
+ except Exception as e:
139
+ from remoterf_device_core.utils.ansi_codes import Sty, printf
140
+
141
+ printf(f"Failed to connect to adalm_pluto_mimo serial={serial}: {e}", Sty.GRAY)
142
+ return None
143
+
144
+
145
+ # ========================= PLUTO MIMO Schema =========================
146
+
147
+ @idl_register("adalm_pluto_mimo")
148
+ class PlutoMIMOSchema(PlutoSchema):
149
+
150
+ device_type = "adalm_pluto_mimo"
151
+ client_class = "PlutoMIMO"
152
+ driver_version = "0.0.2"
153
+
154
+ @staticmethod
155
+ def make_device(**kwargs):
156
+ return connect_pluto_mimo(
157
+ serial=kwargs.get("serial"),
158
+ usb=kwargs.get("usb"),
159
+ usb_port=kwargs.get("usb_port"),
160
+ rx_enabled_channels=kwargs.get("rx_enabled_channels"),
161
+ tx_enabled_channels=kwargs.get("tx_enabled_channels"),
162
+ initial_settings=kwargs,
163
+ )
164
+
165
+ # region MIMO channel selection
166
+
167
+ @idl_expose(kind="get")
168
+ def get_mode(self):
169
+ return "mimo"
170
+
171
+ @idl_expose(kind="get")
172
+ def get_rx_enabled_channels(self):
173
+ return list(self.device.rx_enabled_channels)
174
+
175
+ @idl_expose(kind="set")
176
+ def set_rx_enabled_channels(self, value):
177
+ self.device.rx_enabled_channels = _normalize_channels(value, [0, 1])
178
+
179
+ @idl_expose(kind="get")
180
+ def get_tx_enabled_channels(self):
181
+ return list(self.device.tx_enabled_channels)
182
+
183
+ @idl_expose(kind="set")
184
+ def set_tx_enabled_channels(self, value):
185
+ self.device.tx_enabled_channels = _normalize_channels(value, [0, 1])
186
+
187
+ # endregion
188
+
189
+ # region channel 1 controls
190
+
191
+ @idl_expose(kind="get")
192
+ def get_gain_control_mode_chan1(self):
193
+ return self.device.gain_control_mode_chan1
194
+
195
+ @idl_expose(kind="set")
196
+ def set_gain_control_mode_chan1(self, value):
197
+ self.device.gain_control_mode_chan1 = value
198
+
199
+ @idl_expose(kind="get")
200
+ def get_rx_hardwaregain_chan1(self):
201
+ """RX channel 1 hardware gain (dB)."""
202
+ return _get_attr(self.device, "rx_hardwaregain_chan1", "hardwaregain_chan1")
203
+
204
+ @idl_expose(kind="set")
205
+ def set_rx_hardwaregain_chan1(self, value):
206
+ """RX channel 1 hardware gain (dB)."""
207
+ _set_attr(self.device, value, "rx_hardwaregain_chan1", "hardwaregain_chan1")
208
+
209
+ @idl_expose(kind="get")
210
+ def get_tx_hardwaregain_chan1(self):
211
+ return self.device.tx_hardwaregain_chan1
212
+
213
+ @idl_expose(kind="set")
214
+ def set_tx_hardwaregain_chan1(self, value):
215
+ self.device.tx_hardwaregain_chan1 = value
216
+
217
+ # endregion
218
+
219
+ # region AD9361 gain compatibility
220
+
221
+ @idl_expose(kind="get")
222
+ def get_rx_hardwaregain_chan0(self):
223
+ """RX channel 0 hardware gain (dB)."""
224
+ return _get_attr(self.device, "rx_hardwaregain_chan0", "hardwaregain_chan0")
225
+
226
+ @idl_expose(kind="set")
227
+ def set_rx_hardwaregain_chan0(self, value):
228
+ """RX channel 0 hardware gain (dB)."""
229
+ _set_attr(self.device, value, "rx_hardwaregain_chan0", "hardwaregain_chan0")
230
+
231
+ # endregion