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.
- remoterf_device_core-0.1.0/PKG-INFO +28 -0
- remoterf_device_core-0.1.0/README.md +13 -0
- remoterf_device_core-0.1.0/pyproject.toml +29 -0
- remoterf_device_core-0.1.0/setup.cfg +4 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/__init__.py +14 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/digest.py +23 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/direct.py +180 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/drivers/__init__.py +14 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/drivers/adalm_pluto/__init__.py +14 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/drivers/adalm_pluto/pluto_mimo_schema.py +231 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/drivers/adalm_pluto/pluto_schema.py +445 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/drivers/adalm_pluto/poe.py +255 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/drivers/hackrf/__init__.py +14 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/drivers/hackrf/hackrf_schema.py +448 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/drivers/rtl_sdr/__init__.py +14 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/drivers/rtl_sdr/rtl_sdr_schema.py +351 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/drivers/ti_mmwave/__init__.py +14 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/drivers/ti_mmwave/ti_mmwave_schema.py +667 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/drivers/usrp/__init__.py +14 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/drivers/usrp/policy.py +56 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/drivers/usrp/profiles.py +366 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/drivers/usrp/uhd_manifest.py +1666 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/drivers/usrp/usrp_schema.py +1174 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/drivers/usrp_phaser/__init__.py +14 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/drivers/usrp_phaser/usrp_phaser_schema.py +556 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/grpc/__init__.py +14 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/grpc/grpc.proto +237 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/grpc/grpc_host.proto +132 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/grpc/grpc_host_pb2.py +80 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/grpc/grpc_host_pb2_grpc.py +112 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/grpc/grpc_pb2.py +116 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/grpc/grpc_pb2_grpc.py +477 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/grpc/v2_codec.py +146 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/idl/__init__.py +46 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/idl/schema.py +1216 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/idl/v2.py +311 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/manifest.py +82 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/schemas.json +44 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/utils/__init__.py +14 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/utils/ansi_codes.py +135 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/utils/config.py +22 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/utils/process_arg.py +246 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core/v2_errors.py +114 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core.egg-info/PKG-INFO +28 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core.egg-info/SOURCES.txt +47 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core.egg-info/dependency_links.txt +1 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core.egg-info/requires.txt +8 -0
- remoterf_device_core-0.1.0/src/remoterf_device_core.egg-info/top_level.txt +1 -0
- 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,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
|