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 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()