remotivelabs-cli 0.5.0a1__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.
- remotivelabs/cli/__init__.py +0 -0
- remotivelabs/cli/api/cloud/tokens.py +62 -0
- remotivelabs/cli/broker/__init__.py +33 -0
- remotivelabs/cli/broker/defaults.py +1 -0
- remotivelabs/cli/broker/discovery.py +43 -0
- remotivelabs/cli/broker/export.py +92 -0
- remotivelabs/cli/broker/files.py +119 -0
- remotivelabs/cli/broker/lib/__about__.py +4 -0
- remotivelabs/cli/broker/lib/broker.py +625 -0
- remotivelabs/cli/broker/lib/client.py +224 -0
- remotivelabs/cli/broker/lib/helper.py +277 -0
- remotivelabs/cli/broker/lib/signalcreator.py +196 -0
- remotivelabs/cli/broker/license_flows.py +167 -0
- remotivelabs/cli/broker/licenses.py +98 -0
- remotivelabs/cli/broker/playback.py +117 -0
- remotivelabs/cli/broker/record.py +41 -0
- remotivelabs/cli/broker/recording_session/__init__.py +3 -0
- remotivelabs/cli/broker/recording_session/client.py +67 -0
- remotivelabs/cli/broker/recording_session/cmd.py +254 -0
- remotivelabs/cli/broker/recording_session/time.py +49 -0
- remotivelabs/cli/broker/scripting.py +129 -0
- remotivelabs/cli/broker/signals.py +220 -0
- remotivelabs/cli/broker/version.py +31 -0
- remotivelabs/cli/cloud/__init__.py +17 -0
- remotivelabs/cli/cloud/auth/__init__.py +3 -0
- remotivelabs/cli/cloud/auth/cmd.py +128 -0
- remotivelabs/cli/cloud/auth/login.py +283 -0
- remotivelabs/cli/cloud/auth_tokens.py +149 -0
- remotivelabs/cli/cloud/brokers.py +109 -0
- remotivelabs/cli/cloud/configs.py +109 -0
- remotivelabs/cli/cloud/licenses/__init__.py +0 -0
- remotivelabs/cli/cloud/licenses/cmd.py +14 -0
- remotivelabs/cli/cloud/organisations.py +112 -0
- remotivelabs/cli/cloud/projects.py +44 -0
- remotivelabs/cli/cloud/recordings.py +580 -0
- remotivelabs/cli/cloud/recordings_playback.py +274 -0
- remotivelabs/cli/cloud/resumable_upload.py +87 -0
- remotivelabs/cli/cloud/sample_recordings.py +25 -0
- remotivelabs/cli/cloud/service_account_tokens.py +62 -0
- remotivelabs/cli/cloud/service_accounts.py +72 -0
- remotivelabs/cli/cloud/storage/__init__.py +5 -0
- remotivelabs/cli/cloud/storage/cmd.py +76 -0
- remotivelabs/cli/cloud/storage/copy.py +86 -0
- remotivelabs/cli/cloud/storage/uri_or_path.py +45 -0
- remotivelabs/cli/cloud/uri.py +113 -0
- remotivelabs/cli/connect/__init__.py +0 -0
- remotivelabs/cli/connect/connect.py +118 -0
- remotivelabs/cli/connect/protopie/protopie.py +185 -0
- remotivelabs/cli/py.typed +0 -0
- remotivelabs/cli/remotive.py +123 -0
- remotivelabs/cli/settings/__init__.py +20 -0
- remotivelabs/cli/settings/config_file.py +113 -0
- remotivelabs/cli/settings/core.py +333 -0
- remotivelabs/cli/settings/migration/__init__.py +0 -0
- remotivelabs/cli/settings/migration/migrate_all_token_files.py +80 -0
- remotivelabs/cli/settings/migration/migrate_config_file.py +64 -0
- remotivelabs/cli/settings/migration/migrate_legacy_dirs.py +50 -0
- remotivelabs/cli/settings/migration/migrate_token_file.py +52 -0
- remotivelabs/cli/settings/migration/migration_tools.py +38 -0
- remotivelabs/cli/settings/state_file.py +67 -0
- remotivelabs/cli/settings/token_file.py +128 -0
- remotivelabs/cli/tools/__init__.py +0 -0
- remotivelabs/cli/tools/can/__init__.py +0 -0
- remotivelabs/cli/tools/can/can.py +78 -0
- remotivelabs/cli/tools/tools.py +9 -0
- remotivelabs/cli/topology/__init__.py +28 -0
- remotivelabs/cli/topology/all.py +322 -0
- remotivelabs/cli/topology/cli/__init__.py +3 -0
- remotivelabs/cli/topology/cli/run_in_docker.py +58 -0
- remotivelabs/cli/topology/cli/topology_cli.py +16 -0
- remotivelabs/cli/topology/cmd.py +130 -0
- remotivelabs/cli/topology/start_trial.py +134 -0
- remotivelabs/cli/typer/__init__.py +0 -0
- remotivelabs/cli/typer/typer_utils.py +27 -0
- remotivelabs/cli/utils/__init__.py +0 -0
- remotivelabs/cli/utils/console.py +99 -0
- remotivelabs/cli/utils/rest_helper.py +369 -0
- remotivelabs/cli/utils/time.py +11 -0
- remotivelabs/cli/utils/versions.py +120 -0
- remotivelabs_cli-0.5.0a1.dist-info/METADATA +51 -0
- remotivelabs_cli-0.5.0a1.dist-info/RECORD +84 -0
- remotivelabs_cli-0.5.0a1.dist-info/WHEEL +4 -0
- remotivelabs_cli-0.5.0a1.dist-info/entry_points.txt +3 -0
- remotivelabs_cli-0.5.0a1.dist-info/licenses/LICENSE +17 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import datetime
|
|
5
|
+
from dataclasses import asdict, is_dataclass
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any, AsyncIterator, Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from remotivelabs.broker.auth import ApiKeyAuth, NoAuth
|
|
12
|
+
from remotivelabs.broker.recording_session import RecordingSessionClient, RecordingSessionPlaybackStatus
|
|
13
|
+
from remotivelabs.cli.broker.defaults import DEFAULT_GRPC_URL
|
|
14
|
+
from remotivelabs.cli.broker.recording_session.client import RecursiveFilesListingClient
|
|
15
|
+
from remotivelabs.cli.broker.recording_session.time import time_offset_to_us
|
|
16
|
+
from remotivelabs.cli.typer import typer_utils
|
|
17
|
+
from remotivelabs.cli.utils.console import print_generic_error, print_result
|
|
18
|
+
|
|
19
|
+
app = typer_utils.create_typer(
|
|
20
|
+
help="""
|
|
21
|
+
Manage playback of recording sessions
|
|
22
|
+
|
|
23
|
+
All offsets are in microseconds (μs)
|
|
24
|
+
"""
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _int_or_none(offset: Optional[str | int]) -> Optional[int]:
|
|
29
|
+
return offset if offset is None else int(offset)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _print_offset_help(cmd: str) -> str:
|
|
33
|
+
return f"""
|
|
34
|
+
Offsets can be specified in minutes (1:15min), seconds(10s), millis(10000ms) or micros(10000000us), default without suffix is micros.
|
|
35
|
+
Sample offsets
|
|
36
|
+
{cmd} 1.15min, 10s, 10000ms, 10000000us, 10000000,
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _custom_types(o: Any) -> Any:
|
|
41
|
+
if isinstance(o, Enum):
|
|
42
|
+
return o.name
|
|
43
|
+
if is_dataclass(type(o)):
|
|
44
|
+
return asdict(o)
|
|
45
|
+
if isinstance(o, datetime.datetime):
|
|
46
|
+
return o.isoformat(timespec="seconds")
|
|
47
|
+
raise TypeError(f"Object of type {o.__class__.__name__} is not JSON serializable")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def _list_files_async(path: str, recursive: bool, url: str, api_key: str) -> None:
|
|
51
|
+
if recursive:
|
|
52
|
+
client = RecordingSessionClient(url, auth=ApiKeyAuth(api_key) if api_key else NoAuth())
|
|
53
|
+
file_listing_client = RecursiveFilesListingClient(client)
|
|
54
|
+
print_result(
|
|
55
|
+
await file_listing_client.list_all_files(path, file_types=None),
|
|
56
|
+
default=_custom_types,
|
|
57
|
+
)
|
|
58
|
+
else:
|
|
59
|
+
client = RecordingSessionClient(url, auth=ApiKeyAuth(api_key) if api_key else NoAuth())
|
|
60
|
+
print_result(await client.list_recording_files(path), default=_custom_types)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@app.command()
|
|
64
|
+
def list_files(
|
|
65
|
+
path: str = typer.Argument("/", help="Optional subdirectory to list files in, defaults to /"),
|
|
66
|
+
recursive: bool = typer.Option(False, help="List subdirectories recursively"),
|
|
67
|
+
url: str = typer.Option(DEFAULT_GRPC_URL, help="Broker URL", envvar="REMOTIVE_BROKER_URL"),
|
|
68
|
+
api_key: str = typer.Option("offline", help="Cloud Broker API-KEY or access token", envvar="REMOTIVE_BROKER_API_KEY"),
|
|
69
|
+
) -> None:
|
|
70
|
+
"""
|
|
71
|
+
List files on broker.
|
|
72
|
+
"""
|
|
73
|
+
try:
|
|
74
|
+
asyncio.run(_list_files_async(path, recursive, url, api_key))
|
|
75
|
+
except Exception as e:
|
|
76
|
+
print_generic_error(str(e))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# --------------------------------------------------------------------------- #
|
|
80
|
+
# play
|
|
81
|
+
# --------------------------------------------------------------------------- #
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def _play_async(path: str, offset: str, url: str, api_key: str) -> None:
|
|
85
|
+
client = RecordingSessionClient(url, auth=ApiKeyAuth(api_key) if api_key else NoAuth())
|
|
86
|
+
result = await client.get_session(path=path).play(offset=_int_or_none(offset))
|
|
87
|
+
print_result(result, default=_custom_types)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@app.command(
|
|
91
|
+
help=f"""
|
|
92
|
+
Starts playing the recording at current offset or from specified offset
|
|
93
|
+
{_print_offset_help("--offset")}
|
|
94
|
+
"""
|
|
95
|
+
)
|
|
96
|
+
def play( # noqa: PLR0913
|
|
97
|
+
path: str = typer.Argument(..., help="Path to the recording session", envvar="REMOTIVE_RECORDING_SESSION_PATH"),
|
|
98
|
+
offset: str = typer.Option(None, callback=time_offset_to_us, help="Offset to play from"),
|
|
99
|
+
url: str = typer.Option(DEFAULT_GRPC_URL, help="Broker URL", envvar="REMOTIVE_BROKER_URL"),
|
|
100
|
+
api_key: str = typer.Option("offline", help="Cloud Broker API-KEY or access token", envvar="REMOTIVE_BROKER_API_KEY"),
|
|
101
|
+
) -> None:
|
|
102
|
+
try:
|
|
103
|
+
asyncio.run(_play_async(path, offset, url, api_key))
|
|
104
|
+
except Exception as e:
|
|
105
|
+
print_generic_error(str(e))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def _repeat_async(path: str, start_offset: str, end_offset: str, clear: bool, url: str, api_key: str) -> None: # noqa: PLR0913
|
|
109
|
+
session = RecordingSessionClient(url, auth=ApiKeyAuth(api_key) if api_key else NoAuth()).get_session(path)
|
|
110
|
+
if clear:
|
|
111
|
+
result = await session.set_repeat(start_offset=None, end_offset=None)
|
|
112
|
+
else:
|
|
113
|
+
result = await session.set_repeat(start_offset=int(start_offset), end_offset=_int_or_none(end_offset))
|
|
114
|
+
print_result(result, _custom_types)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@app.command(
|
|
118
|
+
help=f"""
|
|
119
|
+
Repeat RecordingSession in specific interval or complete recording
|
|
120
|
+
To remove existing repeat config, use --clear flag.
|
|
121
|
+
{_print_offset_help("--start-offset/--end-offset")}
|
|
122
|
+
"""
|
|
123
|
+
)
|
|
124
|
+
def repeat( # noqa: PLR0913
|
|
125
|
+
path: str = typer.Argument(..., help="Path to the recording session", envvar="REMOTIVE_RECORDING_SESSION_PATH"),
|
|
126
|
+
start_offset: str = typer.Option(0, callback=time_offset_to_us, help="Repeat start offset, defaults to start"),
|
|
127
|
+
end_offset: str = typer.Option(None, callback=time_offset_to_us, help="Repeat end offset, defaults to end"),
|
|
128
|
+
clear: bool = typer.Option(False, help="Clear repeat"),
|
|
129
|
+
url: str = typer.Option(DEFAULT_GRPC_URL, help="Broker URL", envvar="REMOTIVE_BROKER_URL"),
|
|
130
|
+
api_key: str = typer.Option("offline", help="Cloud Broker API-KEY or access token", envvar="REMOTIVE_BROKER_API_KEY"),
|
|
131
|
+
) -> None:
|
|
132
|
+
try:
|
|
133
|
+
asyncio.run(_repeat_async(path, start_offset, end_offset, clear, url, api_key))
|
|
134
|
+
except Exception as e:
|
|
135
|
+
print_generic_error(str(e))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def _pause_async(path: str, offset: str, url: str, api_key: str) -> None:
|
|
139
|
+
session = RecordingSessionClient(url, auth=ApiKeyAuth(api_key) if api_key else NoAuth()).get_session(path)
|
|
140
|
+
result = await session.pause(offset=_int_or_none(offset))
|
|
141
|
+
print_result(result, default=_custom_types)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@app.command(
|
|
145
|
+
help=f"""
|
|
146
|
+
Pause the recording at current offset or specified offset
|
|
147
|
+
{_print_offset_help("--offset")}
|
|
148
|
+
"""
|
|
149
|
+
)
|
|
150
|
+
def pause(
|
|
151
|
+
path: str = typer.Argument(..., help="Path to the recording session", envvar="REMOTIVE_RECORDING_SESSION_PATH"),
|
|
152
|
+
offset: str = typer.Option(None, callback=time_offset_to_us, help="Offset to play from"),
|
|
153
|
+
url: str = typer.Option(DEFAULT_GRPC_URL, help="Broker URL", envvar="REMOTIVE_BROKER_URL"),
|
|
154
|
+
api_key: str = typer.Option("offline", help="Cloud Broker API-KEY or access token", envvar="REMOTIVE_BROKER_API_KEY"),
|
|
155
|
+
) -> None:
|
|
156
|
+
try:
|
|
157
|
+
asyncio.run(_pause_async(path, offset, url, api_key))
|
|
158
|
+
except Exception as e:
|
|
159
|
+
print_generic_error(str(e))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def _seek_async(path: str, offset: str, url: str, api_key: str) -> None:
|
|
163
|
+
session = RecordingSessionClient(url, auth=ApiKeyAuth(api_key) if api_key else NoAuth()).get_session(path)
|
|
164
|
+
result = await session.seek(offset=int(offset))
|
|
165
|
+
print_result(result, default=_custom_types)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@app.command(
|
|
169
|
+
help=f"""
|
|
170
|
+
Seek to specified offset
|
|
171
|
+
{_print_offset_help("--offset")}
|
|
172
|
+
"""
|
|
173
|
+
)
|
|
174
|
+
def seek(
|
|
175
|
+
path: str = typer.Argument(..., help="Path to the recording session", envvar="REMOTIVE_RECORDING_SESSION_PATH"),
|
|
176
|
+
offset: str = typer.Option(..., callback=time_offset_to_us, help="Offset to seek to"),
|
|
177
|
+
url: str = typer.Option(DEFAULT_GRPC_URL, help="Broker URL", envvar="REMOTIVE_BROKER_URL"),
|
|
178
|
+
api_key: str = typer.Option("offline", help="Cloud Broker API-KEY or access token", envvar="REMOTIVE_BROKER_API_KEY"),
|
|
179
|
+
) -> None:
|
|
180
|
+
try:
|
|
181
|
+
asyncio.run(_seek_async(path, offset, url, api_key))
|
|
182
|
+
except Exception as e:
|
|
183
|
+
print_generic_error(str(e))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
async def _open_async(path: str, force: bool, url: str, api_key: str) -> None:
|
|
187
|
+
session = RecordingSessionClient(url, auth=ApiKeyAuth(api_key) if api_key else NoAuth()).get_session(path)
|
|
188
|
+
result = await session.open(force_reopen=force)
|
|
189
|
+
print_result(result, default=_custom_types)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@app.command()
|
|
193
|
+
def open( # noqa: PLR0913
|
|
194
|
+
path: str = typer.Argument(..., help="Path to the recording session", envvar="REMOTIVE_RECORDING_SESSION_PATH"),
|
|
195
|
+
force: bool = typer.Option(False, help="Force close and re-open recording session if exists"),
|
|
196
|
+
url: str = typer.Option(DEFAULT_GRPC_URL, help="Broker URL", envvar="REMOTIVE_BROKER_URL"),
|
|
197
|
+
api_key: str = typer.Option("offline", help="Cloud Broker API-KEY or access token", envvar="REMOTIVE_BROKER_API_KEY"),
|
|
198
|
+
) -> None:
|
|
199
|
+
"""
|
|
200
|
+
Open a recording session.
|
|
201
|
+
"""
|
|
202
|
+
try:
|
|
203
|
+
asyncio.run(_open_async(path, force, url, api_key))
|
|
204
|
+
except Exception as e:
|
|
205
|
+
print_generic_error(str(e))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
async def _close_async(path: str, url: str, api_key: str) -> None:
|
|
209
|
+
session = RecordingSessionClient(url, auth=ApiKeyAuth(api_key) if api_key else NoAuth()).get_session(path)
|
|
210
|
+
result = await session.close()
|
|
211
|
+
print_result(result, default=_custom_types)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@app.command()
|
|
215
|
+
def close(
|
|
216
|
+
path: str = typer.Argument(..., help="Path to the recording session", envvar="REMOTIVE_RECORDING_SESSION_PATH"),
|
|
217
|
+
url: str = typer.Option(DEFAULT_GRPC_URL, help="Broker URL", envvar="REMOTIVE_BROKER_URL"),
|
|
218
|
+
api_key: str = typer.Option("offline", help="Cloud Broker API-KEY or access token", envvar="REMOTIVE_BROKER_API_KEY"),
|
|
219
|
+
) -> None:
|
|
220
|
+
"""
|
|
221
|
+
Close a recording session.
|
|
222
|
+
"""
|
|
223
|
+
try:
|
|
224
|
+
asyncio.run(_close_async(path, url, api_key))
|
|
225
|
+
except Exception as e:
|
|
226
|
+
print_generic_error(str(e))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
async def _status_async(stream: bool, url: str, api_key: str) -> None:
|
|
230
|
+
client = RecordingSessionClient(url, auth=ApiKeyAuth(api_key) if api_key else NoAuth())
|
|
231
|
+
|
|
232
|
+
async def _async_playback_stream() -> None:
|
|
233
|
+
_stream: AsyncIterator[list[RecordingSessionPlaybackStatus]] = client.playback_status()
|
|
234
|
+
async for f in _stream:
|
|
235
|
+
print_result(f, default=_custom_types)
|
|
236
|
+
if not stream:
|
|
237
|
+
break
|
|
238
|
+
|
|
239
|
+
await _async_playback_stream()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@app.command()
|
|
243
|
+
def status(
|
|
244
|
+
stream: bool = typer.Option(False, help="Blocks and continuously streams statuses"),
|
|
245
|
+
url: str = typer.Option(DEFAULT_GRPC_URL, help="Broker URL", envvar="REMOTIVE_BROKER_URL"),
|
|
246
|
+
api_key: str = typer.Option("offline", help="Cloud Broker API-KEY or access token", envvar="REMOTIVE_BROKER_API_KEY"),
|
|
247
|
+
) -> None:
|
|
248
|
+
"""
|
|
249
|
+
Get the status of all opened Recording sessions
|
|
250
|
+
"""
|
|
251
|
+
try:
|
|
252
|
+
asyncio.run(_status_async(stream, url, api_key))
|
|
253
|
+
except Exception as e:
|
|
254
|
+
print_generic_error(str(e))
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class InvalidOffsetException(typer.BadParameter): # noqa: N818
|
|
10
|
+
"""
|
|
11
|
+
Thrown if invalid time, extends BadParameter to hook into typer validation
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def time_offset_to_us(value: Optional[str]) -> Optional[int]:
|
|
16
|
+
"""
|
|
17
|
+
Parse a time string like '1s', '1ms', '1us', '1.02min' or '30' (default µs) into microseconds (int).
|
|
18
|
+
For minutes: 1.02min == 1 minute 2 seconds
|
|
19
|
+
"""
|
|
20
|
+
if value is None:
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
value = value.strip().lower()
|
|
24
|
+
|
|
25
|
+
# Check if it's a minute format with decimal or colon
|
|
26
|
+
min_match = re.fullmatch(r"([+-]?\d+)[.:](\d+)min", value)
|
|
27
|
+
if min_match:
|
|
28
|
+
minutes = int(min_match.group(1))
|
|
29
|
+
seconds = int(min_match.group(2))
|
|
30
|
+
return (minutes * 60 + seconds) * 1_000_000
|
|
31
|
+
|
|
32
|
+
# Support optional + or - sign before the number
|
|
33
|
+
match = re.fullmatch(r"([+-]?\d+(?:\.\d+)?)(?:\s*(us|µs|ms|s|min))?", value)
|
|
34
|
+
if not match:
|
|
35
|
+
raise InvalidOffsetException(f"Invalid time format: '{value}'")
|
|
36
|
+
|
|
37
|
+
amount, unit = match.groups()
|
|
38
|
+
amount = float(amount)
|
|
39
|
+
unit = unit or "us" # Default to microseconds
|
|
40
|
+
|
|
41
|
+
unit_multipliers = {
|
|
42
|
+
"min": 60_000_000,
|
|
43
|
+
"s": 1_000_000,
|
|
44
|
+
"ms": 1_000,
|
|
45
|
+
"us": 1,
|
|
46
|
+
"µs": 1,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return int(amount * unit_multipliers[unit])
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from string import Template
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from remotivelabs.cli.typer import typer_utils
|
|
10
|
+
from remotivelabs.cli.utils.console import print_generic_message, print_hint
|
|
11
|
+
|
|
12
|
+
app = typer_utils.create_typer(
|
|
13
|
+
rich_markup_mode="rich",
|
|
14
|
+
help="""
|
|
15
|
+
[Experimental] - Generate template lua script for input and output signals
|
|
16
|
+
""",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def write(signal_name: str, script: str) -> None:
|
|
21
|
+
path = f"{signal_name}.lua"
|
|
22
|
+
with open(path, "w") as f:
|
|
23
|
+
f.write(script)
|
|
24
|
+
print_generic_message(f"Secret token written to {path}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.command("new-script")
|
|
28
|
+
def new_script(
|
|
29
|
+
input_signal: List[str] = typer.Option(..., help="Required input signal names"),
|
|
30
|
+
output_signal: str = typer.Option(..., help="Name of output signal"),
|
|
31
|
+
save: bool = typer.Option(False, help="Save file to disk - Default stored as __output_signal__.lua"),
|
|
32
|
+
) -> None:
|
|
33
|
+
def to_subscribable_signal(sig: str) -> tuple[str, str]:
|
|
34
|
+
arr = sig.split(":")
|
|
35
|
+
if len(arr) != 2:
|
|
36
|
+
print_hint(f"--input-signal must have format namespace:signal ({sig})")
|
|
37
|
+
sys.exit(1)
|
|
38
|
+
return arr[0], arr[1]
|
|
39
|
+
|
|
40
|
+
signals_to_subscribe_to = list(map(to_subscribable_signal, input_signal))
|
|
41
|
+
|
|
42
|
+
def to_local_signal(sig_name: tuple[str, str]) -> str:
|
|
43
|
+
t = Template(
|
|
44
|
+
"""
|
|
45
|
+
{
|
|
46
|
+
name = "$sig_name",
|
|
47
|
+
namespace = "$namespace"
|
|
48
|
+
}"""
|
|
49
|
+
)
|
|
50
|
+
return t.substitute(sig_name=sig_name[1], namespace=sig_name[0])
|
|
51
|
+
|
|
52
|
+
local_signals = ",".join(list(map(to_local_signal, signals_to_subscribe_to)))
|
|
53
|
+
|
|
54
|
+
def to_subscribe_pattern(sig_name: tuple[str, str]) -> str:
|
|
55
|
+
t = Template(
|
|
56
|
+
"""
|
|
57
|
+
if (signals["$sig_name"] ~= nil) then
|
|
58
|
+
return return_value_or_bytes(signals["$sig_name"])
|
|
59
|
+
end
|
|
60
|
+
"""
|
|
61
|
+
)
|
|
62
|
+
return t.substitute(sig_name=sig_name[1])
|
|
63
|
+
|
|
64
|
+
subscribe_pattern = "".join(list(map(to_subscribe_pattern, signals_to_subscribe_to)))
|
|
65
|
+
|
|
66
|
+
template = Template(
|
|
67
|
+
"""
|
|
68
|
+
--
|
|
69
|
+
-- Docs available at https://docs.remotivelabs.com/docs/remotive-broker/scripted_signals
|
|
70
|
+
--
|
|
71
|
+
|
|
72
|
+
local local_signals = {$local_signals
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
-- Required, declare which input is needed to operate this program.
|
|
76
|
+
function input_signals()
|
|
77
|
+
return local_signals
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
-- Provided parameters are used for populating metadata when listing signals.
|
|
81
|
+
function output_signal()
|
|
82
|
+
return "$output_signal"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
-- Required, declare what frequency you like to get "timer" invoked. 0 means no calls to "timer".
|
|
86
|
+
function timer_frequency_hz()
|
|
87
|
+
return 0
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
-- Invoked with the frequency returned by "timer_frequency_hz".
|
|
91
|
+
-- @param system_timestamp_us: system time stamp
|
|
92
|
+
function timer(system_timestamp_us)
|
|
93
|
+
return return_value_or_bytes("your value")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
-- Invoked when ANY signal declared in "local_signals" arrive
|
|
97
|
+
-- @param signals_timestamp_us: signal time stamp
|
|
98
|
+
-- @param system_timestamp_us
|
|
99
|
+
-- @param signals: array of signals containing all or a subset of signals declared in "local_signals". Make sure to nil check before use.
|
|
100
|
+
function signals(signals, namespace, signals_timestamp_us, system_timestamp_us)
|
|
101
|
+
-- TODO - replace this code with what you want todo
|
|
102
|
+
|
|
103
|
+
$subscribe_pattern
|
|
104
|
+
return return_nothing()
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
-- helper return function, make sure to use return_value_or_bytes or return_nothing.
|
|
108
|
+
function return_value_or_bytes(value_or_bytes)
|
|
109
|
+
return value_or_bytes
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
-- helper return function, make sure to use return_value_or_bytes or return_nothing.
|
|
113
|
+
function return_nothing()
|
|
114
|
+
return
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
"""
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
script = template.substitute(
|
|
121
|
+
local_signals=local_signals,
|
|
122
|
+
subscribe_pattern=subscribe_pattern,
|
|
123
|
+
output_signal=output_signal,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
if save:
|
|
127
|
+
write(output_signal, script)
|
|
128
|
+
else:
|
|
129
|
+
print_generic_message(script)
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import numbers
|
|
5
|
+
import os
|
|
6
|
+
import signal as os_signal
|
|
7
|
+
import sys
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, Iterable, List, TypedDict, Union
|
|
11
|
+
|
|
12
|
+
import grpc
|
|
13
|
+
import plotext as plt # type: ignore
|
|
14
|
+
import typer
|
|
15
|
+
|
|
16
|
+
from remotivelabs.cli.broker.defaults import DEFAULT_GRPC_URL
|
|
17
|
+
from remotivelabs.cli.broker.lib.broker import Broker, SubscribableSignal
|
|
18
|
+
from remotivelabs.cli.typer import typer_utils
|
|
19
|
+
from remotivelabs.cli.utils.console import print_generic_error, print_generic_message, print_grpc_error, print_hint
|
|
20
|
+
|
|
21
|
+
app = typer_utils.create_typer(help=help)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Signals(TypedDict):
|
|
25
|
+
name: str
|
|
26
|
+
signals: List[Any]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
signal_values: Dict[Any, Any] = {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.command(name="list")
|
|
33
|
+
def list_signals(
|
|
34
|
+
url: str = typer.Option(DEFAULT_GRPC_URL, help="Broker URL", envvar="REMOTIVE_BROKER_URL"),
|
|
35
|
+
api_key: str = typer.Option(None, help="Cloud Broker API-KEY or access token", envvar="REMOTIVE_BROKER_API_KEY"),
|
|
36
|
+
name_starts_with: Union[str, None] = typer.Option(None, help="Signal name prefix to include"),
|
|
37
|
+
name_ends_with: Union[str, None] = typer.Option(None, help="Signal name suffix to include"),
|
|
38
|
+
) -> None:
|
|
39
|
+
"""
|
|
40
|
+
List signal metadata on a broker
|
|
41
|
+
|
|
42
|
+
Filter are inclusive so --name-starts-with and --name-ends-with will include name that matches both
|
|
43
|
+
"""
|
|
44
|
+
try:
|
|
45
|
+
broker = Broker(url, api_key)
|
|
46
|
+
available_signals = broker.list_signal_names(prefix=name_starts_with, suffix=name_ends_with)
|
|
47
|
+
print_generic_message(json.dumps(available_signals))
|
|
48
|
+
except grpc.RpcError as rpc_error:
|
|
49
|
+
print_grpc_error(rpc_error)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def read_scripted_code_file(file_path: Path) -> bytes:
|
|
53
|
+
try:
|
|
54
|
+
print_generic_message(str(file_path))
|
|
55
|
+
with open(file_path, "rb") as file:
|
|
56
|
+
return file.read()
|
|
57
|
+
except FileNotFoundError:
|
|
58
|
+
print_generic_error("File not found. Please check your file path.")
|
|
59
|
+
sys.exit(1)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.command()
|
|
63
|
+
def subscribe( # noqa: C901, PLR0913, PLR0915
|
|
64
|
+
url: str = typer.Option(DEFAULT_GRPC_URL, help="Broker URL", envvar="REMOTIVE_BROKER_URL"),
|
|
65
|
+
api_key: str = typer.Option(None, help="Cloud Broker API-KEY or access token", envvar="REMOTIVE_BROKER_API_KEY"),
|
|
66
|
+
signal: List[str] = typer.Option([], help="Signal names to subscribe to, mandatory when not using script"),
|
|
67
|
+
script: Path = typer.Option(
|
|
68
|
+
None,
|
|
69
|
+
exists=True,
|
|
70
|
+
file_okay=True,
|
|
71
|
+
dir_okay=False,
|
|
72
|
+
writable=False,
|
|
73
|
+
readable=True,
|
|
74
|
+
resolve_path=True,
|
|
75
|
+
help="Supply a path to Lua script that to use for signal transformation",
|
|
76
|
+
),
|
|
77
|
+
on_change_only: bool = typer.Option(default=False, help="Only get signal if value is changed"),
|
|
78
|
+
x_plot: bool = typer.Option(default=False, help="Experimental: Plot the signal in terminal. Note graphs are not aligned by time"),
|
|
79
|
+
x_plot_size: int = typer.Option(default=100, help="Experimental: how many points show for each plot"),
|
|
80
|
+
# samples: int = typer.Option(default=0, he)
|
|
81
|
+
) -> None:
|
|
82
|
+
"""
|
|
83
|
+
Subscribe to a selection of signals
|
|
84
|
+
|
|
85
|
+
Subscribe to two signals and have it printed to terminal
|
|
86
|
+
```
|
|
87
|
+
remotive broker signals subscribe --url http://localhost:50051 --signal can1:signal1 --signal can0:signal2
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Subscribe using a LUA script with signal transformations, read more about scripted signals at https://docs.remotivelabs.com/docs/remotive-broker
|
|
91
|
+
```
|
|
92
|
+
remotive broker signals subscribe --url http://localhost:50051 --script myvss_script.lua
|
|
93
|
+
```
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
if script is None:
|
|
97
|
+
if len(signal) == 0:
|
|
98
|
+
print_generic_error("You must use --signal or use --script when subscribing")
|
|
99
|
+
sys.exit(1)
|
|
100
|
+
|
|
101
|
+
if script is not None:
|
|
102
|
+
if len(signal) > 0:
|
|
103
|
+
print_generic_error("You must must not specify --signal when using --script")
|
|
104
|
+
sys.exit(1)
|
|
105
|
+
|
|
106
|
+
plt.title("Signals")
|
|
107
|
+
|
|
108
|
+
def exit_on_ctrlc(_sig: Any, _frame: Any) -> None:
|
|
109
|
+
os._exit(0)
|
|
110
|
+
|
|
111
|
+
def on_frame_plot(x: Iterable[Any]) -> None:
|
|
112
|
+
plt.clt() # to clear the terminal
|
|
113
|
+
plt.cld() # to clear the data only
|
|
114
|
+
frames = list(x)
|
|
115
|
+
plt.clf()
|
|
116
|
+
plt.subplots(len(list(filter(lambda n: n.startswith("ts_"), signal_values.keys()))))
|
|
117
|
+
plt.theme("pro")
|
|
118
|
+
|
|
119
|
+
for frame in frames:
|
|
120
|
+
name = frame["name"]
|
|
121
|
+
|
|
122
|
+
if not isinstance(frame["value"], numbers.Number):
|
|
123
|
+
# Skip non numberic values
|
|
124
|
+
# TODO - would exit and print info message if I knew how to
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
y = [frame["value"]]
|
|
128
|
+
t = [frame["timestamp_us"]]
|
|
129
|
+
|
|
130
|
+
if name not in signal_values:
|
|
131
|
+
signal_values[name] = [None] * x_plot_size
|
|
132
|
+
signal_values[f"ts_{name}"] = [None] * x_plot_size
|
|
133
|
+
signal_values[name] = signal_values[name] + y
|
|
134
|
+
signal_values[f"ts_{name}"] = signal_values[f"ts_{name}"] + t
|
|
135
|
+
|
|
136
|
+
if len(signal_values[name]) > x_plot_size:
|
|
137
|
+
signal_values[name] = signal_values[name][len(signal_values[name]) - x_plot_size :]
|
|
138
|
+
|
|
139
|
+
if len(signal_values[f"ts_{name}"]) > x_plot_size:
|
|
140
|
+
signal_values[f"ts_{name}"] = signal_values[f"ts_{name}"][len(signal_values[f"ts_{name}"]) - x_plot_size :]
|
|
141
|
+
|
|
142
|
+
cnt = 1
|
|
143
|
+
for key, value in signal_values.items():
|
|
144
|
+
if not key.startswith("ts_"):
|
|
145
|
+
plt.subplot(cnt, 1).plot(signal_values[f"ts_{key}"], value, label=key, color=cnt)
|
|
146
|
+
cnt = cnt + 1
|
|
147
|
+
plt.sleep(0.001) # to add
|
|
148
|
+
plt.show()
|
|
149
|
+
|
|
150
|
+
def on_frame_print(x: Iterable[Any]) -> None:
|
|
151
|
+
# TODO: use log instead of print for debug information?
|
|
152
|
+
print_generic_message(json.dumps(list(x)))
|
|
153
|
+
|
|
154
|
+
os_signal.signal(os_signal.SIGINT, exit_on_ctrlc)
|
|
155
|
+
|
|
156
|
+
if x_plot:
|
|
157
|
+
on_frame_func = on_frame_plot
|
|
158
|
+
else:
|
|
159
|
+
on_frame_func = on_frame_print
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
if script is not None:
|
|
163
|
+
script_src = read_scripted_code_file(script)
|
|
164
|
+
broker = Broker(url, api_key)
|
|
165
|
+
broker.subscribe_on_script(script_src, on_frame_func, on_change_only)
|
|
166
|
+
else:
|
|
167
|
+
|
|
168
|
+
def to_subscribable_signal(sig: str):
|
|
169
|
+
arr = sig.split(":")
|
|
170
|
+
if len(arr) != 2:
|
|
171
|
+
print_hint(f"--signal must have format namespace:signal ({sig})")
|
|
172
|
+
sys.exit(1)
|
|
173
|
+
return SubscribableSignal(namespace=arr[0], name=arr[1])
|
|
174
|
+
|
|
175
|
+
signals_to_subscribe_to = list(map(to_subscribable_signal, signal))
|
|
176
|
+
|
|
177
|
+
broker = Broker(url, api_key)
|
|
178
|
+
broker.long_name_subscribe(signals_to_subscribe_to, on_frame_func, on_change_only)
|
|
179
|
+
print_generic_message("Subscribing to signals, press Ctrl+C to exit")
|
|
180
|
+
except grpc.RpcError as rpc_error:
|
|
181
|
+
print_grpc_error(rpc_error)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@app.command(help="List namespaces on broker")
|
|
185
|
+
def namespaces(
|
|
186
|
+
url: str = typer.Option(DEFAULT_GRPC_URL, help="Broker URL", envvar="REMOTIVE_BROKER_URL"),
|
|
187
|
+
api_key: str = typer.Option(None, help="Cloud Broker API-KEY or access token", envvar="REMOTIVE_BROKER_API_KEY"),
|
|
188
|
+
) -> None:
|
|
189
|
+
try:
|
|
190
|
+
broker = Broker(url, api_key)
|
|
191
|
+
namespaces_json = broker.list_namespaces()
|
|
192
|
+
print_generic_message(json.dumps(namespaces_json))
|
|
193
|
+
except grpc.RpcError as rpc_error:
|
|
194
|
+
print_grpc_error(rpc_error)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@app.command()
|
|
198
|
+
def frame_distribution(
|
|
199
|
+
url: str = typer.Option(DEFAULT_GRPC_URL, help="Broker URL", envvar="REMOTIVE_BROKER_URL"),
|
|
200
|
+
api_key: str = typer.Option(None, help="Cloud Broker API-KEY or access token", envvar="REMOTIVE_BROKER_API_KEY"),
|
|
201
|
+
namespace: str = typer.Option(..., help="Namespace"),
|
|
202
|
+
) -> None:
|
|
203
|
+
"""
|
|
204
|
+
Use this command to get frames currently available on the specified namespace.
|
|
205
|
+
"""
|
|
206
|
+
try:
|
|
207
|
+
broker = Broker(url, api_key)
|
|
208
|
+
|
|
209
|
+
def on_data(data: Dict[str, Any]) -> None:
|
|
210
|
+
timestamp: str = datetime.now().strftime("%H:%M:%S")
|
|
211
|
+
distribution = data["countsByFrameId"]
|
|
212
|
+
if len(distribution) == 0:
|
|
213
|
+
print_hint(f"{timestamp} - No frames available")
|
|
214
|
+
else:
|
|
215
|
+
for d in distribution:
|
|
216
|
+
print_generic_message(f"{timestamp}: {d}")
|
|
217
|
+
|
|
218
|
+
broker.listen_on_frame_distribution(namespace, on_data)
|
|
219
|
+
except grpc.RpcError as rpc_error:
|
|
220
|
+
print_grpc_error(rpc_error)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import semver
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def ensure_version_is_at_least(version: str, min_version: str) -> None:
|
|
5
|
+
"""
|
|
6
|
+
Ensures that broker version is at least a specific version.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
# use finalize() to use the release version of this version regardless of pre-release or build
|
|
11
|
+
broker_version = semver.parse_version_info(version).finalize_version()
|
|
12
|
+
required_version = semver.parse_version_info(min_version).finalize_version()
|
|
13
|
+
except ValueError as e:
|
|
14
|
+
raise InvalidBrokerVersionError(str(e))
|
|
15
|
+
|
|
16
|
+
if broker_version < required_version:
|
|
17
|
+
raise UnsupportedBrokerVersionError(current_version=broker_version, min_version=min_version)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UnsupportedBrokerVersionError(Exception):
|
|
21
|
+
"""Raised when broker version is below the minimum supported version."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, current_version: str, min_version: str):
|
|
24
|
+
self.current_version = current_version
|
|
25
|
+
self.min_version = min_version
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InvalidBrokerVersionError(Exception):
|
|
29
|
+
"""Raised when version is not major.minor.patch"""
|
|
30
|
+
|
|
31
|
+
pass
|