klein-bt 0.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- klein/__init__.py +8 -0
- klein/btlog.py +186 -0
- klein/cli.py +113 -0
- klein/gateway.py +457 -0
- klein/groot2_protocol.py +291 -0
- klein/layout.py +523 -0
- klein/mock_robot.py +961 -0
- klein/recorder.py +184 -0
- klein/recording.py +570 -0
- klein/robot_link.py +124 -0
- klein/routes.py +208 -0
- klein/static/app.js +400 -0
- klein/static/blackboard.js +478 -0
- klein/static/cursor.js +108 -0
- klein/static/d3.v7.min.js +2 -0
- klein/static/drawer.js +241 -0
- klein/static/index.html +222 -0
- klein/static/log.js +360 -0
- klein/static/overview.js +320 -0
- klein/static/recording.js +807 -0
- klein/static/renderers.js +314 -0
- klein/static/styles.css +1602 -0
- klein/static/timeline.js +453 -0
- klein/static/transport.js +200 -0
- klein/static/tree.js +856 -0
- klein/static/ui.js +248 -0
- klein/static/view.js +253 -0
- klein/static/window.js +166 -0
- klein/streaming.py +202 -0
- klein_bt-0.8.0.dist-info/METADATA +204 -0
- klein_bt-0.8.0.dist-info/RECORD +35 -0
- klein_bt-0.8.0.dist-info/WHEEL +5 -0
- klein_bt-0.8.0.dist-info/entry_points.txt +3 -0
- klein_bt-0.8.0.dist-info/licenses/LICENSE +21 -0
- klein_bt-0.8.0.dist-info/top_level.txt +1 -0
klein/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""klein — a lightweight CLI telemetry dashboard for BehaviorTree.CPP v4.
|
|
2
|
+
|
|
3
|
+
Connects to a running robot node over ZeroMQ (the Groot2 publisher protocol),
|
|
4
|
+
unrolls nested subtrees, and streams live status and blackboard values to an
|
|
5
|
+
interactive D3.js browser dashboard. Run ``klein-bt --help`` for options.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.8.0"
|
klein/btlog.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""klein.btlog — FileLogger2 ``.btlog`` files, read and written in memory.
|
|
2
|
+
|
|
3
|
+
The format BehaviorTree.CPP's ``FileLogger2`` writes and Groot2 opens
|
|
4
|
+
(``bt_file_logger_v2.cpp``; the header comment in ``bt_file_logger_v2.h``
|
|
5
|
+
leaves out the magic and the version byte — the .cpp is authoritative).
|
|
6
|
+
All little-endian::
|
|
7
|
+
|
|
8
|
+
b"BTCPP4-FileLogger2" | u8 version (1) | i32 xml_len | xml (UTF-8)
|
|
9
|
+
| i64 first_timestamp (µs since the epoch)
|
|
10
|
+
| N x 9-byte records: u48 offset_us (from first_timestamp) | u16 uid | u8 status
|
|
11
|
+
|
|
12
|
+
A record is exactly a GET_TRANSITIONS record, so the packing is shared with
|
|
13
|
+
``groot2_protocol``. Statuses are live NodeStatus ints (IDLE is a plain 0).
|
|
14
|
+
|
|
15
|
+
``export_run`` and ``export_blackboard`` save one tree run of a ``Recording``
|
|
16
|
+
(docs/protocol.md, "Saving a recording"); ``load_btlog`` turns a file and its
|
|
17
|
+
sidecar back into one (``klein-bt --open``, docs/protocol.md, "Opening a file").
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import collections
|
|
21
|
+
import itertools
|
|
22
|
+
import json
|
|
23
|
+
import struct
|
|
24
|
+
|
|
25
|
+
from .groot2_protocol import TRANSITION_RECORD_SIZE, decode_transitions, encode_transition
|
|
26
|
+
from .recording import Recording, apply_transition, diff_to_transitions
|
|
27
|
+
|
|
28
|
+
MAGIC = b"BTCPP4-FileLogger2"
|
|
29
|
+
VERSION = 1
|
|
30
|
+
_XML_OFFSET = len(MAGIC) + 1 + 4 # the XML starts after magic, version, xml_len
|
|
31
|
+
_TIMESTAMP_SIZE = 8
|
|
32
|
+
|
|
33
|
+
BtlogFile = collections.namedtuple("BtlogFile", "xml first_timestamp records trailing")
|
|
34
|
+
BtlogFile.__doc__ = ("A parsed ``.btlog``. ``records`` are ``(offset_us, uid, status)``; "
|
|
35
|
+
"``trailing`` counts the bytes of an incomplete last record.")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BtlogError(ValueError):
|
|
39
|
+
"""The data is not a FileLogger2 ``.btlog`` klein can read."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def read_btlog(data):
|
|
43
|
+
"""Parse ``.btlog`` bytes into a ``BtlogFile``.
|
|
44
|
+
|
|
45
|
+
A trailing partial record — a robot stopped mid-write — is left out of
|
|
46
|
+
``records`` and counted in ``trailing``, not raised.
|
|
47
|
+
"""
|
|
48
|
+
if not data.startswith(MAGIC):
|
|
49
|
+
raise BtlogError("not a FileLogger2 .btlog (no BTCPP4-FileLogger2 magic)")
|
|
50
|
+
version = data[len(MAGIC):len(MAGIC) + 1]
|
|
51
|
+
if version != bytes([VERSION]):
|
|
52
|
+
shown = version[0] if version else "missing"
|
|
53
|
+
raise BtlogError(f"unknown .btlog format version {shown}")
|
|
54
|
+
xml_len = int.from_bytes(data[len(MAGIC) + 1:_XML_OFFSET], "little", signed=True)
|
|
55
|
+
if not 0 <= xml_len <= len(data) - _XML_OFFSET - _TIMESTAMP_SIZE:
|
|
56
|
+
raise BtlogError(f"XML length {xml_len} does not fit the file")
|
|
57
|
+
body = _XML_OFFSET + xml_len + _TIMESTAMP_SIZE # where the records start
|
|
58
|
+
xml = data[_XML_OFFSET:_XML_OFFSET + xml_len].decode("utf-8")
|
|
59
|
+
(first_timestamp,) = struct.unpack_from("<q", data, body - _TIMESTAMP_SIZE)
|
|
60
|
+
records = data[body:]
|
|
61
|
+
return BtlogFile(xml, first_timestamp, decode_transitions(records),
|
|
62
|
+
len(records) % TRANSITION_RECORD_SIZE)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _header(xml, first_timestamp):
|
|
66
|
+
body = xml.encode("utf-8")
|
|
67
|
+
return b"".join([MAGIC, bytes([VERSION]), struct.pack("<i", len(body)), body,
|
|
68
|
+
struct.pack("<q", first_timestamp)])
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def write_btlog(xml, first_timestamp, records):
|
|
72
|
+
"""Serialize a ``.btlog``; ``records`` are ``(offset_us, uid, status)``."""
|
|
73
|
+
return b"".join([_header(xml, first_timestamp)]
|
|
74
|
+
+ [encode_transition(*record) for record in records])
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def export_run(run, xml):
|
|
78
|
+
"""One tree run (``Recording.runs()``) as ``.btlog`` bytes.
|
|
79
|
+
|
|
80
|
+
``first_timestamp`` is the run's retained start. Replayed from all-IDLE, as
|
|
81
|
+
Groot2 and klein start a file, the file gives the recorded state at every
|
|
82
|
+
retained time: each segment opens with the transitions from the state
|
|
83
|
+
before it (all-IDLE for the first) to its retained start — the prefix at
|
|
84
|
+
offset 0, and at each restart or re-arm inside the run.
|
|
85
|
+
|
|
86
|
+
A record ``apply_transition`` ignores (an IDLE on a node already idle: an
|
|
87
|
+
arm-overlap record the baseline already held) is left out, so a reader
|
|
88
|
+
with the plain publisher rule never sees "was IDLE".
|
|
89
|
+
"""
|
|
90
|
+
first = run[0].t_start
|
|
91
|
+
state = bytearray(len(run[0].baseline)) # the file's state, as written so far
|
|
92
|
+
out = bytearray(_header(xml, first))
|
|
93
|
+
for segment in run:
|
|
94
|
+
start = segment.state_at_seq(segment.start_seq)
|
|
95
|
+
opening = [(segment.t_start, uid, status)
|
|
96
|
+
for uid, status in diff_to_transitions(state, start)]
|
|
97
|
+
recorded = ((ts, uid, status) for _seq, ts, uid, status
|
|
98
|
+
in segment.iter_records(segment.start_seq, segment.head_seq))
|
|
99
|
+
for ts, uid, status in itertools.chain(opening, recorded):
|
|
100
|
+
if apply_transition(state, uid, status):
|
|
101
|
+
out += encode_transition(ts - first, uid, status)
|
|
102
|
+
return bytes(out)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def export_blackboard(run, tree_id):
|
|
106
|
+
"""One tree run's blackboard history as ``.bb.jsonl`` bytes: a header line,
|
|
107
|
+
then one line per change at its offset from the run's retained start.
|
|
108
|
+
|
|
109
|
+
Each segment opens with the lines that turn the previous values (none, for
|
|
110
|
+
the first) into its values at its retained start, so the file stands alone
|
|
111
|
+
after eviction and across restarts.
|
|
112
|
+
"""
|
|
113
|
+
first = run[0].t_start
|
|
114
|
+
lines = [json.dumps({"klein_blackboard": 1, "first_timestamp": first, "tree_id": tree_id})]
|
|
115
|
+
values = {} # (board, key) -> JSON text, as of the last line
|
|
116
|
+
|
|
117
|
+
def line(t, board, key, text):
|
|
118
|
+
entry = json.dumps({"t": t - first, "board": board, "key": key})[:-1]
|
|
119
|
+
if text is None:
|
|
120
|
+
del values[board, key]
|
|
121
|
+
lines.append(f'{entry}, "removed": true}}')
|
|
122
|
+
else:
|
|
123
|
+
values[board, key] = text # the stored JSON text goes in as it is
|
|
124
|
+
lines.append(f'{entry}, "value": {text}}}')
|
|
125
|
+
|
|
126
|
+
for segment in run:
|
|
127
|
+
track = segment.blackboard
|
|
128
|
+
if track.t_start is None:
|
|
129
|
+
continue
|
|
130
|
+
base = max(track.t_start, first)
|
|
131
|
+
at_base, later = {}, []
|
|
132
|
+
for t, board, key, text in track.changes():
|
|
133
|
+
if t <= base:
|
|
134
|
+
at_base[board, key] = text
|
|
135
|
+
else:
|
|
136
|
+
later.append((t, board, key, text))
|
|
137
|
+
for name in [name for name in values if at_base.get(name) is None]:
|
|
138
|
+
line(base, *name, None)
|
|
139
|
+
for name, text in at_base.items():
|
|
140
|
+
if text is not None and values.get(name) != text:
|
|
141
|
+
line(base, *name, text)
|
|
142
|
+
for change in later:
|
|
143
|
+
line(*change)
|
|
144
|
+
return "".join(entry + "\n" for entry in lines).encode("utf-8")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def load_btlog(log, layout, sidecar=None):
|
|
148
|
+
"""A ``Recording`` of one parsed file (``read_btlog``), as the gateway
|
|
149
|
+
would have recorded it: one ended segment from ``first_timestamp``,
|
|
150
|
+
replayed from all-IDLE as Groot2 starts a file, with its head at the last
|
|
151
|
+
record.
|
|
152
|
+
|
|
153
|
+
``layout`` is the file's ``TreeLayout`` (``parse_layout`` of its XML),
|
|
154
|
+
the segment's layout. ``sidecar`` is the ``.bb.jsonl`` text
|
|
155
|
+
(``export_blackboard``), or None. Its lines become blackboard samples
|
|
156
|
+
holding every board in the layout's ``blackboard_names``, so a board with
|
|
157
|
+
no lines (no keys of its own) still shows, empty, as it does live. The
|
|
158
|
+
first sample is at the first line, or at the sidecar's start when it has
|
|
159
|
+
none.
|
|
160
|
+
"""
|
|
161
|
+
recording = Recording()
|
|
162
|
+
segment = recording.begin_segment(layout, log.first_timestamp, bytes(layout.size))
|
|
163
|
+
recording.append([(log.first_timestamp + offset, uid, status)
|
|
164
|
+
for offset, uid, status in log.records])
|
|
165
|
+
if sidecar is not None:
|
|
166
|
+
_load_blackboard(recording, sidecar, layout.blackboard_names)
|
|
167
|
+
recording.end_segment(segment.last_ts)
|
|
168
|
+
recording.advance_head(segment.last_ts)
|
|
169
|
+
return recording
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _load_blackboard(recording, text, boards):
|
|
173
|
+
header, *lines = [json.loads(line) for line in text.splitlines() if line.strip()]
|
|
174
|
+
first = header["first_timestamp"]
|
|
175
|
+
values = {board: {} for board in boards}
|
|
176
|
+
by_time = collections.defaultdict(list)
|
|
177
|
+
for line in lines:
|
|
178
|
+
by_time[line["t"]].append(line)
|
|
179
|
+
for t in sorted(by_time) or [0]:
|
|
180
|
+
for line in by_time[t]:
|
|
181
|
+
entries = values.setdefault(line["board"], {})
|
|
182
|
+
if line.get("removed"):
|
|
183
|
+
entries.pop(line["key"], None)
|
|
184
|
+
else:
|
|
185
|
+
entries[line["key"]] = line["value"]
|
|
186
|
+
recording.add_blackboard(first + t, values)
|
klein/cli.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""klein.cli — the ``klein-bt`` command: parse the flags, build the gateway
|
|
2
|
+
(``klein/gateway.py``) and run it until Ctrl-C."""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import asyncio
|
|
6
|
+
import math
|
|
7
|
+
import socket
|
|
8
|
+
import sys
|
|
9
|
+
import xml.etree.ElementTree as ET
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .btlog import BtlogError
|
|
13
|
+
from .gateway import KleinGateway
|
|
14
|
+
from .recording import Recording
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _duration_us(text):
|
|
18
|
+
"""``--record-buffer`` value -> µs: ``90s``, ``10m``, ``1h``; a bare number
|
|
19
|
+
is seconds, and ``0`` turns recording off."""
|
|
20
|
+
units = {"s": 1, "m": 60, "h": 3600}
|
|
21
|
+
text = text.strip().lower()
|
|
22
|
+
scale = units.get(text[-1:])
|
|
23
|
+
try:
|
|
24
|
+
value = float(text[:-1] if scale else text)
|
|
25
|
+
except ValueError:
|
|
26
|
+
value = math.nan
|
|
27
|
+
if not math.isfinite(value):
|
|
28
|
+
raise argparse.ArgumentTypeError(f"not a duration: {text!r} (try 90s, 10m, 1h)")
|
|
29
|
+
if value < 0:
|
|
30
|
+
raise argparse.ArgumentTypeError("the duration must not be negative")
|
|
31
|
+
return round(value * (scale or 1) * 1_000_000)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _port_available(port):
|
|
35
|
+
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
36
|
+
# Match the asyncio server's bind semantics (SO_REUSEADDR): reject a port
|
|
37
|
+
# only when a live listener holds it, not when harmless TIME_WAIT sockets
|
|
38
|
+
# linger from a just-closed session (which would block an immediate restart).
|
|
39
|
+
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
40
|
+
try:
|
|
41
|
+
probe.bind(("0.0.0.0", port))
|
|
42
|
+
return True
|
|
43
|
+
except OSError:
|
|
44
|
+
return False
|
|
45
|
+
finally:
|
|
46
|
+
probe.close()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main_cli():
|
|
50
|
+
parser = argparse.ArgumentParser(
|
|
51
|
+
prog="klein-bt",
|
|
52
|
+
description="Live BehaviorTree.CPP v4 telemetry dashboard over ZeroMQ.",
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument("--robot-host", default="127.0.0.1",
|
|
55
|
+
help="IP address of the C++ robot node (default: 127.0.0.1)")
|
|
56
|
+
parser.add_argument("--robot-port", type=int, default=1667,
|
|
57
|
+
help="ZeroMQ REQ/REP port on the robot (default: 1667)")
|
|
58
|
+
parser.add_argument("--port", type=int, default=8080,
|
|
59
|
+
help="port for the klein dashboard + telemetry server (default: 8080)")
|
|
60
|
+
parser.add_argument("--no-browser", action="store_true",
|
|
61
|
+
help="do not auto-open the system browser")
|
|
62
|
+
parser.add_argument("--record-buffer", type=_duration_us, default="10m",
|
|
63
|
+
metavar="DURATION",
|
|
64
|
+
help="how much recent history to record, e.g. 90s, 30m, 1h; 0 turns "
|
|
65
|
+
"recording off and polls the robot only while a dashboard is "
|
|
66
|
+
"open (default: 10m)")
|
|
67
|
+
parser.add_argument("--debug", action="store_true",
|
|
68
|
+
help="serve a read-only JSON dump of the recording at /debug/state")
|
|
69
|
+
parser.add_argument("--open", metavar="FILE.btlog", type=Path,
|
|
70
|
+
help="show a saved FileLogger2 recording (and its FILE.bb.jsonl "
|
|
71
|
+
"blackboard, if present) instead of a robot")
|
|
72
|
+
args = parser.parse_args()
|
|
73
|
+
|
|
74
|
+
recording = None
|
|
75
|
+
if args.open is None and args.record_buffer:
|
|
76
|
+
# Size caps are Recording's defaults: 200 MiB in all, 64 MiB of it blackboard.
|
|
77
|
+
recording = Recording(keep_us=args.record_buffer)
|
|
78
|
+
gateway = KleinGateway(args.robot_host, args.robot_port, args.port,
|
|
79
|
+
recording=recording, debug=args.debug)
|
|
80
|
+
if args.open is not None:
|
|
81
|
+
try:
|
|
82
|
+
trailing = gateway.open_file(args.open)
|
|
83
|
+
except (OSError, BtlogError, ET.ParseError, ValueError) as exc:
|
|
84
|
+
print(f"[klein] cannot open {args.open}: {exc}", file=sys.stderr)
|
|
85
|
+
sys.exit(1)
|
|
86
|
+
if trailing:
|
|
87
|
+
print(f"[klein] {args.open}: ignored a partial last record "
|
|
88
|
+
f"({trailing} byte{'s' if trailing > 1 else ''}).")
|
|
89
|
+
|
|
90
|
+
if not _port_available(args.port):
|
|
91
|
+
print(f"[klein] port {args.port} is already in use — try a different --port.",
|
|
92
|
+
file=sys.stderr)
|
|
93
|
+
sys.exit(1)
|
|
94
|
+
|
|
95
|
+
print()
|
|
96
|
+
print(" klein — BehaviorTree.CPP telemetry")
|
|
97
|
+
if args.open is not None:
|
|
98
|
+
print(f" file : {args.open} (no robot)")
|
|
99
|
+
else:
|
|
100
|
+
print(f" robot : tcp://{args.robot_host}:{args.robot_port}")
|
|
101
|
+
print(f" dashboard : http://localhost:{args.port}")
|
|
102
|
+
print()
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
# The browser is opened from inside run(), once the server is listening,
|
|
106
|
+
# so it never races ahead of the socket being ready.
|
|
107
|
+
asyncio.run(gateway.run(open_browser=not args.no_browser))
|
|
108
|
+
except KeyboardInterrupt:
|
|
109
|
+
print("\n[klein] shutting down.")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
if __name__ == "__main__":
|
|
113
|
+
main_cli()
|