foxglove-sdk 0.16.2__cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.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.
Potentially problematic release.
This version of foxglove-sdk might be problematic. Click here for more details.
- foxglove/__init__.py +245 -0
- foxglove/_foxglove_py/__init__.pyi +211 -0
- foxglove/_foxglove_py/channels.pyi +2792 -0
- foxglove/_foxglove_py/cloud.pyi +9 -0
- foxglove/_foxglove_py/mcap.pyi +120 -0
- foxglove/_foxglove_py/schemas.pyi +1009 -0
- foxglove/_foxglove_py/schemas_wkt.pyi +85 -0
- foxglove/_foxglove_py/websocket.pyi +394 -0
- foxglove/_foxglove_py.cpython-313-arm-linux-gnueabihf.so +0 -0
- foxglove/benchmarks/test_mcap_serialization.py +160 -0
- foxglove/channel.py +241 -0
- foxglove/channels/__init__.py +94 -0
- foxglove/cloud.py +61 -0
- foxglove/mcap.py +12 -0
- foxglove/notebook/__init__.py +0 -0
- foxglove/notebook/foxglove_widget.py +100 -0
- foxglove/notebook/notebook_buffer.py +114 -0
- foxglove/notebook/static/widget.js +1 -0
- foxglove/py.typed +0 -0
- foxglove/schemas/__init__.py +163 -0
- foxglove/tests/__init__.py +0 -0
- foxglove/tests/test_channel.py +243 -0
- foxglove/tests/test_context.py +10 -0
- foxglove/tests/test_logging.py +62 -0
- foxglove/tests/test_mcap.py +368 -0
- foxglove/tests/test_parameters.py +178 -0
- foxglove/tests/test_schemas.py +17 -0
- foxglove/tests/test_server.py +141 -0
- foxglove/tests/test_time.py +137 -0
- foxglove/websocket.py +220 -0
- foxglove_sdk-0.16.2.dist-info/METADATA +53 -0
- foxglove_sdk-0.16.2.dist-info/RECORD +33 -0
- foxglove_sdk-0.16.2.dist-info/WHEEL +5 -0
foxglove/channel.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from base64 import b64encode
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
from . import Context
|
|
9
|
+
from . import _foxglove_py as _foxglove
|
|
10
|
+
from . import channels as _channels
|
|
11
|
+
from . import schemas as _schemas
|
|
12
|
+
|
|
13
|
+
JsonSchema = dict[str, Any]
|
|
14
|
+
JsonMessage = dict[str, Any]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Channel:
|
|
18
|
+
"""
|
|
19
|
+
A channel that can be used to log binary messages or JSON messages.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
__slots__ = ["base"]
|
|
23
|
+
base: _foxglove.BaseChannel
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
topic: str,
|
|
28
|
+
*,
|
|
29
|
+
schema: JsonSchema | _foxglove.Schema | None = None,
|
|
30
|
+
message_encoding: str | None = None,
|
|
31
|
+
context: Context | None = None,
|
|
32
|
+
metadata: dict[str, str] | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""
|
|
35
|
+
Create a new channel for logging messages on a topic.
|
|
36
|
+
|
|
37
|
+
:param topic: The topic name. You should choose a unique topic name per channel.
|
|
38
|
+
:param message_encoding: The message encoding. Optional if :py:param:`schema` is a
|
|
39
|
+
dictionary, in which case the message encoding is presumed to be "json".
|
|
40
|
+
:param schema: A definition of your schema. Pass a :py:class:`Schema` for full control. If a
|
|
41
|
+
dictionary is passed, it will be treated as a JSON schema.
|
|
42
|
+
:param metadata: A dictionary of key/value strings to add to the channel. A type error is
|
|
43
|
+
raised if any key or value is not a string.
|
|
44
|
+
|
|
45
|
+
If both message_encoding and schema are None, then the channel will use JSON encoding, and
|
|
46
|
+
allow any dict to be logged.
|
|
47
|
+
"""
|
|
48
|
+
message_encoding, schema = _normalize_schema(message_encoding, schema)
|
|
49
|
+
|
|
50
|
+
if context is not None:
|
|
51
|
+
self.base = context._create_channel(
|
|
52
|
+
topic, message_encoding=message_encoding, schema=schema
|
|
53
|
+
)
|
|
54
|
+
else:
|
|
55
|
+
self.base = _foxglove.BaseChannel(
|
|
56
|
+
topic,
|
|
57
|
+
message_encoding,
|
|
58
|
+
schema,
|
|
59
|
+
metadata,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
_channels_by_id[self.base.id()] = self
|
|
63
|
+
|
|
64
|
+
def __repr__(self) -> str:
|
|
65
|
+
return f"Channel(id={self.id()}, topic='{self.topic()}', schema='{self.schema_name()}')"
|
|
66
|
+
|
|
67
|
+
def id(self) -> int:
|
|
68
|
+
"""The unique ID of the channel"""
|
|
69
|
+
return self.base.id()
|
|
70
|
+
|
|
71
|
+
def topic(self) -> str:
|
|
72
|
+
"""The topic name of the channel"""
|
|
73
|
+
return self.base.topic()
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def message_encoding(self) -> str:
|
|
77
|
+
"""The message encoding for the channel"""
|
|
78
|
+
return self.base.message_encoding
|
|
79
|
+
|
|
80
|
+
def metadata(self) -> dict[str, str]:
|
|
81
|
+
"""
|
|
82
|
+
Returns a copy of the channel's metadata.
|
|
83
|
+
|
|
84
|
+
Note that changes made to the returned dictionary will not be applied to
|
|
85
|
+
the channel's metadata.
|
|
86
|
+
"""
|
|
87
|
+
return self.base.metadata()
|
|
88
|
+
|
|
89
|
+
def schema(self) -> _foxglove.Schema | None:
|
|
90
|
+
"""
|
|
91
|
+
Returns a copy of the channel's metadata.
|
|
92
|
+
|
|
93
|
+
Note that changes made to the returned object will not be applied to
|
|
94
|
+
the channel's schema.
|
|
95
|
+
"""
|
|
96
|
+
return self.base.schema()
|
|
97
|
+
|
|
98
|
+
def schema_name(self) -> str | None:
|
|
99
|
+
"""The name of the schema for the channel"""
|
|
100
|
+
return self.base.schema_name()
|
|
101
|
+
|
|
102
|
+
def has_sinks(self) -> bool:
|
|
103
|
+
"""Returns true if at least one sink is subscribed to this channel"""
|
|
104
|
+
return self.base.has_sinks()
|
|
105
|
+
|
|
106
|
+
def log(
|
|
107
|
+
self,
|
|
108
|
+
msg: JsonMessage | list[Any] | bytes | str,
|
|
109
|
+
*,
|
|
110
|
+
log_time: int | None = None,
|
|
111
|
+
sink_id: int | None = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
"""
|
|
114
|
+
Log a message on the channel.
|
|
115
|
+
|
|
116
|
+
:param msg: the message to log. If the channel uses JSON encoding, you may pass a
|
|
117
|
+
dictionary or list. Otherwise, you are responsible for serializing the message.
|
|
118
|
+
:param log_time: The optional time the message was logged.
|
|
119
|
+
"""
|
|
120
|
+
if self.message_encoding == "json" and isinstance(msg, (dict, list)):
|
|
121
|
+
return self.base.log(json.dumps(msg).encode("utf-8"), log_time)
|
|
122
|
+
|
|
123
|
+
if isinstance(msg, str):
|
|
124
|
+
msg = msg.encode("utf-8")
|
|
125
|
+
|
|
126
|
+
if isinstance(msg, bytes):
|
|
127
|
+
return self.base.log(msg, log_time, sink_id)
|
|
128
|
+
|
|
129
|
+
raise TypeError(f"Unsupported message type: {type(msg)}")
|
|
130
|
+
|
|
131
|
+
def close(self) -> None:
|
|
132
|
+
"""
|
|
133
|
+
Close the channel.
|
|
134
|
+
|
|
135
|
+
You can use this to explicitly unadvertise the channel to sinks that subscribe to
|
|
136
|
+
channels dynamically, such as the :py:class:`foxglove.websocket.WebSocketServer`.
|
|
137
|
+
|
|
138
|
+
Attempts to log on a closed channel will elicit a throttled warning message.
|
|
139
|
+
"""
|
|
140
|
+
self.base.close()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
_channels_by_id: dict[int, Channel] = {}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def log(
|
|
147
|
+
topic: str,
|
|
148
|
+
message: JsonMessage | list[Any] | bytes | str | _schemas.FoxgloveSchema,
|
|
149
|
+
*,
|
|
150
|
+
log_time: int | None = None,
|
|
151
|
+
sink_id: int | None = None,
|
|
152
|
+
) -> None:
|
|
153
|
+
"""Log a message on a topic.
|
|
154
|
+
|
|
155
|
+
Creates a new channel the first time called for a given topic.
|
|
156
|
+
For Foxglove types in the schemas module, this creates a typed channel
|
|
157
|
+
(see :py:mod:`foxglove.channels` for supported types).
|
|
158
|
+
For bytes and str, this creates a simple schemaless channel and logs the bytes as-is.
|
|
159
|
+
For dict and list, this creates a schemaless json channel.
|
|
160
|
+
|
|
161
|
+
The type of the message must be kept consistent for each topic or an error will be raised.
|
|
162
|
+
This can be avoided by creating and using the channels directly instead of using this function.
|
|
163
|
+
|
|
164
|
+
Note: this raises an error if a channel with the same topic was created by other means.
|
|
165
|
+
This limitation may be lifted in the future.
|
|
166
|
+
|
|
167
|
+
:param topic: The topic name.
|
|
168
|
+
:param message: The message to log.
|
|
169
|
+
:param log_time: The optional time the message was logged.
|
|
170
|
+
"""
|
|
171
|
+
base_channel = _foxglove.get_channel_for_topic(topic)
|
|
172
|
+
channel = _channels_by_id.get(base_channel.id(), None) if base_channel else None
|
|
173
|
+
|
|
174
|
+
if channel is None:
|
|
175
|
+
schema_name = type(message).__name__
|
|
176
|
+
if isinstance(message, (bytes, str)):
|
|
177
|
+
channel = Channel(topic)
|
|
178
|
+
elif isinstance(message, (dict, list)):
|
|
179
|
+
channel = Channel(topic, message_encoding="json")
|
|
180
|
+
else:
|
|
181
|
+
channel_name = f"{schema_name}Channel"
|
|
182
|
+
channel_cls = getattr(_channels, channel_name, None)
|
|
183
|
+
if channel_cls is not None:
|
|
184
|
+
channel = channel_cls(topic)
|
|
185
|
+
if channel is None:
|
|
186
|
+
raise ValueError(
|
|
187
|
+
f"No Foxglove schema channel found for message type {schema_name}"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
channel_id = channel.base.id() if hasattr(channel, "base") else channel.id()
|
|
191
|
+
_channels_by_id[channel_id] = channel
|
|
192
|
+
|
|
193
|
+
# mypy isn't smart enough to realize that when channel is a Channel, message a compatible type
|
|
194
|
+
channel.log(
|
|
195
|
+
cast(Any, message),
|
|
196
|
+
log_time=log_time,
|
|
197
|
+
sink_id=sink_id,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _normalize_schema(
|
|
202
|
+
message_encoding: str | None,
|
|
203
|
+
schema: JsonSchema | _foxglove.Schema | None = None,
|
|
204
|
+
) -> tuple[str, _foxglove.Schema | None]:
|
|
205
|
+
if isinstance(schema, _foxglove.Schema):
|
|
206
|
+
if message_encoding is None:
|
|
207
|
+
raise ValueError("message encoding is required")
|
|
208
|
+
return message_encoding, schema
|
|
209
|
+
|
|
210
|
+
if schema is None and (message_encoding is None or message_encoding == "json"):
|
|
211
|
+
# Schemaless support via JSON encoding; same as specifying an empty dict schema
|
|
212
|
+
schema = {}
|
|
213
|
+
message_encoding = "json"
|
|
214
|
+
|
|
215
|
+
if isinstance(schema, dict):
|
|
216
|
+
# Dicts default to json encoding. An empty dict is equivalent to the empty schema (b"")
|
|
217
|
+
if message_encoding and message_encoding != "json":
|
|
218
|
+
raise ValueError("message_encoding must be 'json' when schema is a dict")
|
|
219
|
+
if schema and schema.get("type") != "object":
|
|
220
|
+
raise ValueError("Only object schemas are supported")
|
|
221
|
+
|
|
222
|
+
data = json.dumps(schema).encode("utf-8") if schema else b""
|
|
223
|
+
name = schema["title"] if "title" in schema else _default_schema_name(data)
|
|
224
|
+
|
|
225
|
+
return (
|
|
226
|
+
"json",
|
|
227
|
+
_foxglove.Schema(
|
|
228
|
+
name=name,
|
|
229
|
+
encoding="jsonschema",
|
|
230
|
+
data=data,
|
|
231
|
+
),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
raise TypeError(f"Invalid schema type: {type(schema)}")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _default_schema_name(data: bytes) -> str:
|
|
238
|
+
# Provide a consistent, readable, and reasonably unique name for a given schema so the app can
|
|
239
|
+
# identify it to the user.
|
|
240
|
+
hash = hashlib.shake_128(data).digest(6)
|
|
241
|
+
return "schema-" + b64encode(hash, b"-_").decode("utf-8")
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module defines channels to easily log messages conforming to well-known Foxglove schemas.
|
|
3
|
+
|
|
4
|
+
See the :py:mod:`foxglove.schemas` module for available definitions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# Generated by https://github.com/foxglove/foxglove-sdk
|
|
8
|
+
from foxglove._foxglove_py.channels import (
|
|
9
|
+
ArrowPrimitiveChannel,
|
|
10
|
+
CameraCalibrationChannel,
|
|
11
|
+
CircleAnnotationChannel,
|
|
12
|
+
ColorChannel,
|
|
13
|
+
CompressedImageChannel,
|
|
14
|
+
CompressedVideoChannel,
|
|
15
|
+
CubePrimitiveChannel,
|
|
16
|
+
CylinderPrimitiveChannel,
|
|
17
|
+
FrameTransformChannel,
|
|
18
|
+
FrameTransformsChannel,
|
|
19
|
+
GeoJsonChannel,
|
|
20
|
+
GridChannel,
|
|
21
|
+
ImageAnnotationsChannel,
|
|
22
|
+
KeyValuePairChannel,
|
|
23
|
+
LaserScanChannel,
|
|
24
|
+
LinePrimitiveChannel,
|
|
25
|
+
LocationFixChannel,
|
|
26
|
+
LocationFixesChannel,
|
|
27
|
+
LogChannel,
|
|
28
|
+
ModelPrimitiveChannel,
|
|
29
|
+
PackedElementFieldChannel,
|
|
30
|
+
Point2Channel,
|
|
31
|
+
Point3Channel,
|
|
32
|
+
PointCloudChannel,
|
|
33
|
+
PointsAnnotationChannel,
|
|
34
|
+
PoseChannel,
|
|
35
|
+
PoseInFrameChannel,
|
|
36
|
+
PosesInFrameChannel,
|
|
37
|
+
QuaternionChannel,
|
|
38
|
+
RawAudioChannel,
|
|
39
|
+
RawImageChannel,
|
|
40
|
+
SceneEntityChannel,
|
|
41
|
+
SceneEntityDeletionChannel,
|
|
42
|
+
SceneUpdateChannel,
|
|
43
|
+
SpherePrimitiveChannel,
|
|
44
|
+
TextAnnotationChannel,
|
|
45
|
+
TextPrimitiveChannel,
|
|
46
|
+
TriangleListPrimitiveChannel,
|
|
47
|
+
Vector2Channel,
|
|
48
|
+
Vector3Channel,
|
|
49
|
+
VoxelGridChannel,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"ArrowPrimitiveChannel",
|
|
54
|
+
"CameraCalibrationChannel",
|
|
55
|
+
"CircleAnnotationChannel",
|
|
56
|
+
"ColorChannel",
|
|
57
|
+
"CompressedImageChannel",
|
|
58
|
+
"CompressedVideoChannel",
|
|
59
|
+
"CubePrimitiveChannel",
|
|
60
|
+
"CylinderPrimitiveChannel",
|
|
61
|
+
"FrameTransformChannel",
|
|
62
|
+
"FrameTransformsChannel",
|
|
63
|
+
"GeoJsonChannel",
|
|
64
|
+
"GridChannel",
|
|
65
|
+
"ImageAnnotationsChannel",
|
|
66
|
+
"KeyValuePairChannel",
|
|
67
|
+
"LaserScanChannel",
|
|
68
|
+
"LinePrimitiveChannel",
|
|
69
|
+
"LocationFixChannel",
|
|
70
|
+
"LocationFixesChannel",
|
|
71
|
+
"LogChannel",
|
|
72
|
+
"ModelPrimitiveChannel",
|
|
73
|
+
"PackedElementFieldChannel",
|
|
74
|
+
"Point2Channel",
|
|
75
|
+
"Point3Channel",
|
|
76
|
+
"PointCloudChannel",
|
|
77
|
+
"PointsAnnotationChannel",
|
|
78
|
+
"PoseChannel",
|
|
79
|
+
"PoseInFrameChannel",
|
|
80
|
+
"PosesInFrameChannel",
|
|
81
|
+
"QuaternionChannel",
|
|
82
|
+
"RawAudioChannel",
|
|
83
|
+
"RawImageChannel",
|
|
84
|
+
"SceneEntityChannel",
|
|
85
|
+
"SceneEntityDeletionChannel",
|
|
86
|
+
"SceneUpdateChannel",
|
|
87
|
+
"SpherePrimitiveChannel",
|
|
88
|
+
"TextAnnotationChannel",
|
|
89
|
+
"TextPrimitiveChannel",
|
|
90
|
+
"TriangleListPrimitiveChannel",
|
|
91
|
+
"Vector2Channel",
|
|
92
|
+
"Vector3Channel",
|
|
93
|
+
"VoxelGridChannel",
|
|
94
|
+
]
|
foxglove/cloud.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from typing import Protocol
|
|
2
|
+
|
|
3
|
+
from ._foxglove_py.websocket import (
|
|
4
|
+
ChannelView,
|
|
5
|
+
Client,
|
|
6
|
+
ClientChannel,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CloudSinkListener(Protocol):
|
|
11
|
+
"""
|
|
12
|
+
A mechanism to register callbacks for handling client message events.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def on_subscribe(self, client: Client, channel: ChannelView) -> None:
|
|
16
|
+
"""
|
|
17
|
+
Called when a client subscribes to a channel.
|
|
18
|
+
|
|
19
|
+
:param client: The client (id) that sent the message.
|
|
20
|
+
:param channel: The channel (id, topic) that the message was sent on.
|
|
21
|
+
"""
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
def on_unsubscribe(self, client: Client, channel: ChannelView) -> None:
|
|
25
|
+
"""
|
|
26
|
+
Called when a client unsubscribes from a channel or disconnects.
|
|
27
|
+
|
|
28
|
+
:param client: The client (id) that sent the message.
|
|
29
|
+
:param channel: The channel (id, topic) that the message was sent on.
|
|
30
|
+
"""
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
def on_client_advertise(self, client: Client, channel: ClientChannel) -> None:
|
|
34
|
+
"""
|
|
35
|
+
Called when a client advertises a channel.
|
|
36
|
+
|
|
37
|
+
:param client: The client (id) that sent the message.
|
|
38
|
+
:param channel: The client channel that is being advertised.
|
|
39
|
+
"""
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
def on_client_unadvertise(self, client: Client, client_channel_id: int) -> None:
|
|
43
|
+
"""
|
|
44
|
+
Called when a client unadvertises a channel.
|
|
45
|
+
|
|
46
|
+
:param client: The client (id) that is unadvertising the channel.
|
|
47
|
+
:param client_channel_id: The client channel id that is being unadvertised.
|
|
48
|
+
"""
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
def on_message_data(
|
|
52
|
+
self, client: Client, client_channel_id: int, data: bytes
|
|
53
|
+
) -> None:
|
|
54
|
+
"""
|
|
55
|
+
Called when a message is received from a client.
|
|
56
|
+
|
|
57
|
+
:param client: The client (id) that sent the message.
|
|
58
|
+
:param client_channel_id: The client channel id that the message was sent on.
|
|
59
|
+
:param data: The message data.
|
|
60
|
+
"""
|
|
61
|
+
return None
|
foxglove/mcap.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pathlib
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
5
|
+
|
|
6
|
+
import anywidget
|
|
7
|
+
import traitlets
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from .notebook_buffer import NotebookBuffer
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FoxgloveWidget(anywidget.AnyWidget):
|
|
14
|
+
"""
|
|
15
|
+
A widget that displays a Foxglove viewer in a notebook.
|
|
16
|
+
|
|
17
|
+
:param buffer: The NotebookBuffer object that contains the data to display in the widget.
|
|
18
|
+
:param layout_storage_key: The storage key of the layout to use for the widget.
|
|
19
|
+
:param width: The width of the widget. Defaults to "full".
|
|
20
|
+
:param height: The height of the widget in pixels. Defaults to 500.
|
|
21
|
+
:param src: The source URL of the Foxglove viewer. Defaults to "https://embed.foxglove.dev/".
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
_esm = pathlib.Path(__file__).parent / "static" / "widget.js"
|
|
25
|
+
width = traitlets.Union(
|
|
26
|
+
[traitlets.Int(), traitlets.Enum(values=["full"])], default_value="full"
|
|
27
|
+
).tag(sync=True)
|
|
28
|
+
height = traitlets.Int(default_value=500).tag(sync=True)
|
|
29
|
+
src = traitlets.Unicode(default_value=None, allow_none=True).tag(sync=True)
|
|
30
|
+
_layout_params = traitlets.Dict(
|
|
31
|
+
per_key_traits={
|
|
32
|
+
"storage_key": traitlets.Unicode(),
|
|
33
|
+
"opaque_layout": traitlets.Dict(allow_none=True, default_value=None),
|
|
34
|
+
"force": traitlets.Bool(False),
|
|
35
|
+
},
|
|
36
|
+
allow_none=True,
|
|
37
|
+
default_value=None,
|
|
38
|
+
).tag(sync=True)
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
buffer: NotebookBuffer,
|
|
43
|
+
layout_storage_key: str,
|
|
44
|
+
width: int | Literal["full"] | None = None,
|
|
45
|
+
height: int | None = None,
|
|
46
|
+
src: str | None = None,
|
|
47
|
+
**kwargs: Any,
|
|
48
|
+
):
|
|
49
|
+
super().__init__(**kwargs)
|
|
50
|
+
if width is not None:
|
|
51
|
+
self.width = width
|
|
52
|
+
else:
|
|
53
|
+
self.width = "full"
|
|
54
|
+
if height is not None:
|
|
55
|
+
self.height = height
|
|
56
|
+
if src is not None:
|
|
57
|
+
self.src = src
|
|
58
|
+
|
|
59
|
+
self.select_layout(layout_storage_key, **kwargs)
|
|
60
|
+
|
|
61
|
+
# Callback to get the data to display in the widget
|
|
62
|
+
self._buffer = buffer
|
|
63
|
+
# Keep track of when the widget is ready to receive data
|
|
64
|
+
self._ready = False
|
|
65
|
+
# Pending data to be sent when the widget is ready
|
|
66
|
+
self._pending_data: list[bytes] = []
|
|
67
|
+
self.on_msg(self._handle_custom_msg)
|
|
68
|
+
self.refresh()
|
|
69
|
+
|
|
70
|
+
def select_layout(self, storage_key: str, **kwargs: Any) -> None:
|
|
71
|
+
"""
|
|
72
|
+
Select a layout in the Foxglove viewer.
|
|
73
|
+
"""
|
|
74
|
+
opaque_layout = kwargs.get("opaque_layout", None)
|
|
75
|
+
force_layout = kwargs.get("force_layout", False)
|
|
76
|
+
|
|
77
|
+
self._layout_params = {
|
|
78
|
+
"storage_key": storage_key,
|
|
79
|
+
"opaque_layout": opaque_layout if isinstance(opaque_layout, dict) else None,
|
|
80
|
+
"force": force_layout,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
def refresh(self) -> None:
|
|
84
|
+
"""
|
|
85
|
+
Refresh the widget by getting the data from the callback function and sending it
|
|
86
|
+
to the widget.
|
|
87
|
+
"""
|
|
88
|
+
data = self._buffer.get_data()
|
|
89
|
+
if not self._ready:
|
|
90
|
+
self._pending_data = data
|
|
91
|
+
else:
|
|
92
|
+
self.send({"type": "update-data"}, data)
|
|
93
|
+
|
|
94
|
+
def _handle_custom_msg(self, msg: dict, buffers: list[bytes]) -> None:
|
|
95
|
+
if msg["type"] == "ready":
|
|
96
|
+
self._ready = True
|
|
97
|
+
|
|
98
|
+
if len(self._pending_data) > 0:
|
|
99
|
+
self.send({"type": "update-data"}, self._pending_data)
|
|
100
|
+
self._pending_data = []
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import uuid
|
|
5
|
+
from tempfile import TemporaryDirectory
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
from mcap.reader import make_reader
|
|
9
|
+
|
|
10
|
+
from .._foxglove_py import Context, open_mcap
|
|
11
|
+
from .foxglove_widget import FoxgloveWidget
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NotebookBuffer:
|
|
15
|
+
"""
|
|
16
|
+
A data buffer to collect and manage messages and visualization in Jupyter notebooks.
|
|
17
|
+
|
|
18
|
+
The NotebookBuffer object will buffer all data logged to the provided context. When you
|
|
19
|
+
are ready to visualize the data, you can call the :meth:`show` method to display an embedded
|
|
20
|
+
Foxglove visualization widget. The widget provides a fully-featured Foxglove interface
|
|
21
|
+
directly within your Jupyter notebook, allowing you to explore multi-modal robotics data
|
|
22
|
+
including 3D scenes, plots, images, and more.
|
|
23
|
+
|
|
24
|
+
:param context: The Context used to log the messages. If no Context is provided, the global
|
|
25
|
+
context will be used. Logged messages will be buffered.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, context: Context | None = None):
|
|
29
|
+
"""
|
|
30
|
+
Initialize a new NotebookBuffer for collecting logged messages.
|
|
31
|
+
"""
|
|
32
|
+
# We need to keep the temporary directory alive until the writer is closed
|
|
33
|
+
self._temp_directory = TemporaryDirectory()
|
|
34
|
+
self._context = context
|
|
35
|
+
self._files: list[str] = []
|
|
36
|
+
self._create_writer()
|
|
37
|
+
|
|
38
|
+
def show(
|
|
39
|
+
self,
|
|
40
|
+
layout_storage_key: str,
|
|
41
|
+
width: int | Literal["full"] | None = None,
|
|
42
|
+
height: int | None = None,
|
|
43
|
+
src: str | None = None,
|
|
44
|
+
**kwargs: Any,
|
|
45
|
+
) -> FoxgloveWidget:
|
|
46
|
+
"""
|
|
47
|
+
Show the Foxglove viewer. Call this method as the last step of a notebook cell
|
|
48
|
+
to display the viewer.
|
|
49
|
+
"""
|
|
50
|
+
widget = FoxgloveWidget(
|
|
51
|
+
buffer=self,
|
|
52
|
+
width=width,
|
|
53
|
+
height=height,
|
|
54
|
+
src=src,
|
|
55
|
+
layout_storage_key=layout_storage_key,
|
|
56
|
+
**kwargs,
|
|
57
|
+
)
|
|
58
|
+
return widget
|
|
59
|
+
|
|
60
|
+
def clear(self) -> None:
|
|
61
|
+
"""
|
|
62
|
+
Clear the buffered data.
|
|
63
|
+
"""
|
|
64
|
+
self._writer.close()
|
|
65
|
+
# Delete the temporary directory and all its contents
|
|
66
|
+
self._temp_directory.cleanup()
|
|
67
|
+
# Reset files list
|
|
68
|
+
self._files = []
|
|
69
|
+
# Create a new temporary directory
|
|
70
|
+
self._temp_directory = TemporaryDirectory()
|
|
71
|
+
self._create_writer()
|
|
72
|
+
|
|
73
|
+
def get_data(self) -> list[bytes]:
|
|
74
|
+
"""
|
|
75
|
+
Retrieve all collected data.
|
|
76
|
+
"""
|
|
77
|
+
# close the current writer
|
|
78
|
+
self._writer.close()
|
|
79
|
+
|
|
80
|
+
if len(self._files) > 1:
|
|
81
|
+
if is_mcap_empty(self._files[-1]):
|
|
82
|
+
# If the last file is empty, remove the last file since it won't add any new data
|
|
83
|
+
# to the buffer
|
|
84
|
+
os.remove(self._files[-1])
|
|
85
|
+
self._files.pop()
|
|
86
|
+
elif is_mcap_empty(self._files[0]):
|
|
87
|
+
# If the first file is empty, remove the first file since it won't add any new data
|
|
88
|
+
# to the buffer
|
|
89
|
+
os.remove(self._files[0])
|
|
90
|
+
self._files.pop(0)
|
|
91
|
+
|
|
92
|
+
# read the content of the files
|
|
93
|
+
contents: list[bytes] = []
|
|
94
|
+
for file_name in self._files:
|
|
95
|
+
with open(file_name, "rb") as f_read:
|
|
96
|
+
contents.append(f_read.read())
|
|
97
|
+
|
|
98
|
+
self._create_writer()
|
|
99
|
+
|
|
100
|
+
return contents
|
|
101
|
+
|
|
102
|
+
def _create_writer(self) -> None:
|
|
103
|
+
random_id = uuid.uuid4().hex[:8]
|
|
104
|
+
file_name = f"{self._temp_directory.name}/log-{random_id}.mcap"
|
|
105
|
+
self._files.append(file_name)
|
|
106
|
+
self._writer = open_mcap(path=file_name, context=self._context)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def is_mcap_empty(file_name: str) -> bool:
|
|
110
|
+
with open(file_name, "rb") as f_read:
|
|
111
|
+
iter = make_reader(f_read).iter_messages()
|
|
112
|
+
is_empty = next(iter, None) is None
|
|
113
|
+
|
|
114
|
+
return is_empty
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var y=EventTarget,p="https://embed.foxglove.dev/",g="foxglove.default-layout";function c(){let t,e;return{promise:new Promise((s,i)=>{t=s,e=i}),resolve:t,reject:e}}var h=class extends y{#e;#i;#h;#r=!1;#n;#t={dataSource:void 0,selectLayout:void 0,extension:[]};#a;#s=!1;constructor(e){super();let{parent:a,src:s,orgSlug:i,initialDataSource:o,initialLayout:n,initialLayoutParams:r,initialExtensions:l,colorScheme:d="auto"}=e;this.#h=i,this.#n=c();let u=s??p;try{this.#i=new URL(u)}catch{throw new Error(`[FoxgloveViewer] Invalid server URL: ${u}`)}window.addEventListener("message",this.#d),o&&this.setDataSource(o),n!=null&&r==null&&this.setLayoutData(n),r!=null&&this.selectLayout(r),l&&this.installExtensions(l),this.#e=document.createElement("iframe"),this.#e.src=u,this.#e.title="Foxglove",this.#e.allow="cross-origin-isolated",this.#e.style.width="100%",this.#e.style.height="100%",this.#e.style.border="none",this.setColorScheme(d),a.appendChild(this.#e)}setDataSource(e){this.#l({type:"set-data-source",payload:e})}selectLayout(e){this.#l({type:"select-layout",payload:e})}async getLayout(){return this.#a||(this.#a=c(),await this.#n.promise,this.#o({type:"get-layout"})),await this.#a.promise}setLayoutData(e){this.selectLayout({storageKey:g,opaqueLayout:e,force:!0})}installExtensions(e){this.#l({type:"install-extension",payload:e})}isReady(){return this.#r}destroy(){this.#s=!0,this.#e.remove(),window.removeEventListener("message",this.#d)}isDestroyed(){return this.#s}setColorScheme(e){this.#e.style.colorScheme=e==="auto"?"normal":e}#l(e){switch(e.type){case"install-extension":this.#t.extension.push(e);break;case"set-data-source":this.#t.dataSource=e;break;case"select-layout":this.#t.selectLayout=e;break}this.#r&&this.#o(e)}#o(e){if(this.#s){console.warn("[FoxgloveViewer] Unable to post command. Frame has been destroyed.");return}f(this.#e.contentWindow,"Invariant: iframe should be loaded."),this.#e.contentWindow.postMessage(e,this.#i.href)}#d=e=>{let a=new URL(e.origin);if(!(e.source!==this.#e.contentWindow||a.href!==this.#i.href)){if(this.#s){console.warn("[FoxgloveViewer] Unable to handle message. Frame has been destroyed.");return}switch(e.data.type){case"foxglove-origin-request":this.#o({type:"origin-ack"});break;case"foxglove-handshake-request":this.#r=!0,this.#n.resolve(),this.#o({type:"handshake-ack",payload:{orgSlug:this.#h,initialDataSource:this.#t.dataSource?.payload,initialLayoutParams:this.#t.selectLayout?.payload,initialExtensions:this.#t.extension.flatMap(s=>s.payload)}});break;case"foxglove-handshake-complete":this.dispatchEvent(new Event("ready"));break;case"foxglove-error":this.dispatchEvent(new CustomEvent("error",{detail:e.data.payload}));break;case"foxglove-layout-data":this.#a?(this.#a.resolve(e.data.payload),this.#a=void 0):console.warn("[FoxgloveViewer] Received layout but getLayout was not called.");break;default:console.warn("[FoxgloveViewer] Unhandled message type:",e.data);break}}}};function f(t,e="no additional info provided"){if(!t)throw new Error("Assertion Error: "+e)}function w({model:t,el:e}){let a=document.createElement("div"),s=t.get("_layout_params"),i=new h({parent:a,src:t.get("src"),orgSlug:void 0,initialLayoutParams:s?{storageKey:s.storage_key,opaqueLayout:s.opaque_layout,force:s.force}:void 0});i.addEventListener("ready",()=>{t.send({type:"ready"})}),t.on("msg:custom",(o,n)=>{if(o.type==="update-data"){let r=n.map((l,d)=>new File([l.buffer],`data-${d}.mcap`));i.setDataSource({type:"file",file:r})}}),a.style.width=t.get("width")==="full"?"100%":`${t.get("width")}px`,a.style.height=`${t.get("height")}px`,t.on("change:width",()=>{a.style.width=t.get("width")==="full"?"100%":`${t.get("width")}px`}),t.on("change:height",()=>{a.style.height=`${t.get("height")}px`}),t.on("change:_layout_params",()=>{let o=t.get("_layout_params");o&&i.selectLayout({storageKey:o.storage_key,opaqueLayout:o.opaque_layout,force:o.force})}),e.appendChild(a)}var b={render:w};export{b as default};
|
foxglove/py.typed
ADDED
|
File without changes
|