yarbo-local 0.1.0.dev0__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.
- yarbo_local/__init__.py +93 -0
- yarbo_local/blackbox.py +110 -0
- yarbo_local/capture.py +140 -0
- yarbo_local/cli.py +264 -0
- yarbo_local/client.py +231 -0
- yarbo_local/codec.py +140 -0
- yarbo_local/data/codes.yaml +93 -0
- yarbo_local/data/commands.yaml +680 -0
- yarbo_local/data/fields.yaml +4159 -0
- yarbo_local/discover.py +244 -0
- yarbo_local/dump.py +110 -0
- yarbo_local/exceptions.py +65 -0
- yarbo_local/feedback.py +177 -0
- yarbo_local/fieldmap.py +207 -0
- yarbo_local/lifecycle.py +325 -0
- yarbo_local/models.py +904 -0
- yarbo_local/observer.py +173 -0
- yarbo_local/obstacles.py +205 -0
- yarbo_local/preflight.py +100 -0
- yarbo_local/probe.py +234 -0
- yarbo_local/py.typed +0 -0
- yarbo_local/redact.py +260 -0
- yarbo_local/registry.py +184 -0
- yarbo_local/resolve.py +105 -0
- yarbo_local/session.py +434 -0
- yarbo_local/simulator.py +408 -0
- yarbo_local/sitecheck.py +841 -0
- yarbo_local/status.py +90 -0
- yarbo_local/studio/__init__.py +4 -0
- yarbo_local/studio/server.py +440 -0
- yarbo_local/studio/static/app.js +683 -0
- yarbo_local/studio/static/index.html +13 -0
- yarbo_local/studio/static/style.css +17 -0
- yarbo_local/studio/static/vendor/README.md +12 -0
- yarbo_local/studio/static/vendor/lit.js +44 -0
- yarbo_local/topics.py +84 -0
- yarbo_local/transport.py +264 -0
- yarbo_local-0.1.0.dev0.dist-info/METADATA +144 -0
- yarbo_local-0.1.0.dev0.dist-info/RECORD +42 -0
- yarbo_local-0.1.0.dev0.dist-info/WHEEL +4 -0
- yarbo_local-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- yarbo_local-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
yarbo_local/__init__.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Local-first library and tooling for Yarbo robots over their LAN MQTT broker.
|
|
2
|
+
|
|
3
|
+
The library talks to the anonymous broker on the robot and to nothing else.
|
|
4
|
+
``YarboRobot`` is the entry point; ``Session`` is the protocol layer under it;
|
|
5
|
+
``Simulator`` stands in for a robot when there is none on the bench.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .blackbox import FlightRecorder
|
|
11
|
+
from .client import YarboRobot
|
|
12
|
+
from .exceptions import (
|
|
13
|
+
CommandError,
|
|
14
|
+
CommandRefusedError,
|
|
15
|
+
ConnectionLostError,
|
|
16
|
+
ControllerError,
|
|
17
|
+
PlanStartError,
|
|
18
|
+
PreflightError,
|
|
19
|
+
ReplyTimeoutError,
|
|
20
|
+
RobotNotFoundError,
|
|
21
|
+
YarboError,
|
|
22
|
+
)
|
|
23
|
+
from .feedback import AreaProgress, BarrierPoints, PlanFeedback, RechargeFeedback
|
|
24
|
+
from .lifecycle import EventKind, FinishReason, LifecycleEvent, Phase, PlanTracker
|
|
25
|
+
from .models import (
|
|
26
|
+
Activity,
|
|
27
|
+
ChargingPoint,
|
|
28
|
+
Fault,
|
|
29
|
+
Feedback,
|
|
30
|
+
GgaFix,
|
|
31
|
+
GpsReference,
|
|
32
|
+
PlanError,
|
|
33
|
+
PlanSummary,
|
|
34
|
+
RobotState,
|
|
35
|
+
SiteMap,
|
|
36
|
+
Zone,
|
|
37
|
+
)
|
|
38
|
+
from .obstacles import Barrier, ObstacleTracker, Run
|
|
39
|
+
from .preflight import Action, Refusal
|
|
40
|
+
from .registry import Command, Registry
|
|
41
|
+
from .resolve import resolve
|
|
42
|
+
from .session import Session
|
|
43
|
+
from .simulator import Simulator
|
|
44
|
+
from .transport import FakeBroker, FakeTransport, MqttTransport
|
|
45
|
+
|
|
46
|
+
__version__ = "0.1.0.dev0"
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"Action",
|
|
50
|
+
"Activity",
|
|
51
|
+
"AreaProgress",
|
|
52
|
+
"Barrier",
|
|
53
|
+
"BarrierPoints",
|
|
54
|
+
"ChargingPoint",
|
|
55
|
+
"Command",
|
|
56
|
+
"CommandError",
|
|
57
|
+
"CommandRefusedError",
|
|
58
|
+
"ConnectionLostError",
|
|
59
|
+
"ControllerError",
|
|
60
|
+
"EventKind",
|
|
61
|
+
"FakeBroker",
|
|
62
|
+
"FakeTransport",
|
|
63
|
+
"Fault",
|
|
64
|
+
"Feedback",
|
|
65
|
+
"FinishReason",
|
|
66
|
+
"FlightRecorder",
|
|
67
|
+
"GgaFix",
|
|
68
|
+
"GpsReference",
|
|
69
|
+
"LifecycleEvent",
|
|
70
|
+
"MqttTransport",
|
|
71
|
+
"ObstacleTracker",
|
|
72
|
+
"Phase",
|
|
73
|
+
"PlanError",
|
|
74
|
+
"PlanFeedback",
|
|
75
|
+
"PlanStartError",
|
|
76
|
+
"PlanSummary",
|
|
77
|
+
"PlanTracker",
|
|
78
|
+
"PreflightError",
|
|
79
|
+
"RechargeFeedback",
|
|
80
|
+
"Refusal",
|
|
81
|
+
"Registry",
|
|
82
|
+
"ReplyTimeoutError",
|
|
83
|
+
"RobotNotFoundError",
|
|
84
|
+
"RobotState",
|
|
85
|
+
"Run",
|
|
86
|
+
"Session",
|
|
87
|
+
"Simulator",
|
|
88
|
+
"SiteMap",
|
|
89
|
+
"YarboError",
|
|
90
|
+
"YarboRobot",
|
|
91
|
+
"Zone",
|
|
92
|
+
"resolve",
|
|
93
|
+
]
|
yarbo_local/blackbox.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""A flight recorder: the last few minutes of traffic, kept in memory, redacted on the way out.
|
|
2
|
+
|
|
3
|
+
A report that says "it stopped" cannot be acted on; one that carries what the robot and the
|
|
4
|
+
app said in the minutes before can. This keeps that, bounded in count and age, and costs
|
|
5
|
+
nothing until someone asks for it. Nothing is written to disk and nothing leaves the process.
|
|
6
|
+
|
|
7
|
+
What is kept: every command the app or we sent, every reply, the feedback topics thinned to
|
|
8
|
+
one in ``THIN_EVERY`` seconds, and a state frame only when one of the fields that tell the
|
|
9
|
+
story changed (with the frame before it, so the change can be read). Map blobs and paths are
|
|
10
|
+
cut to their size: the site's shape is not needed to debug a protocol exchange.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections import deque
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from . import codec
|
|
20
|
+
from .redact import Redactor
|
|
21
|
+
from .session import MessageEvent, Session
|
|
22
|
+
|
|
23
|
+
MAX_RECORDS = 600
|
|
24
|
+
MAX_AGE_S = 900.0
|
|
25
|
+
THIN_EVERY = 10.0
|
|
26
|
+
BIG = 2000 # characters; anything longer is a map, a path or a blob
|
|
27
|
+
|
|
28
|
+
STORY_KEYS = (
|
|
29
|
+
"StateMSG.on_going_planning",
|
|
30
|
+
"StateMSG.planning_paused",
|
|
31
|
+
"StateMSG.on_going_recharging",
|
|
32
|
+
"StateMSG.error_code",
|
|
33
|
+
"StateMSG.plan_msg",
|
|
34
|
+
"StateMSG.car_controller",
|
|
35
|
+
"StateMSG.machine_controller",
|
|
36
|
+
"StateMSG.charging_status",
|
|
37
|
+
"BatteryMSG.status",
|
|
38
|
+
"HeadMsg.head_type",
|
|
39
|
+
"RTKMSG.status",
|
|
40
|
+
)
|
|
41
|
+
QUIET_APP = frozenset({"cmd_vel", "cmd_roller"}) # joystick streams; forbidden and noisy
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _cut(value: Any) -> Any:
|
|
45
|
+
"""Replace anything large with its size, keeping the shape of the message."""
|
|
46
|
+
if isinstance(value, dict):
|
|
47
|
+
return {k: _cut(v) for k, v in value.items()}
|
|
48
|
+
if isinstance(value, list):
|
|
49
|
+
return f"<{len(value)} items>" if len(value) > 20 else [_cut(v) for v in value]
|
|
50
|
+
if isinstance(value, str) and len(value) > BIG:
|
|
51
|
+
return f"<{len(value)} chars>"
|
|
52
|
+
return value
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class FlightRecorder:
|
|
56
|
+
"""Attach to a session; ``dump()`` returns the recent past, redacted."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, *, max_records: int = MAX_RECORDS, max_age: float = MAX_AGE_S) -> None:
|
|
59
|
+
self._records: deque[dict[str, Any]] = deque(maxlen=max_records)
|
|
60
|
+
self._max_age = max_age
|
|
61
|
+
self._story: tuple[Any, ...] | None = None
|
|
62
|
+
self._held: dict[str, Any] | None = None # the latest state frame not yet recorded
|
|
63
|
+
self._last_thin: dict[str, float] = {}
|
|
64
|
+
self._serials: set[str] = set()
|
|
65
|
+
|
|
66
|
+
def attach(self, session: Session) -> Callable[[], None]:
|
|
67
|
+
return session.add_message_listener(self.on_message)
|
|
68
|
+
|
|
69
|
+
def note(self, at: float, what: str, **detail: Any) -> None:
|
|
70
|
+
"""Something the consumer knows and the wire does not: a refusal, a reconnect."""
|
|
71
|
+
self._records.append({"t": round(at, 3), "note": what, **detail})
|
|
72
|
+
|
|
73
|
+
def on_message(self, event: MessageEvent) -> None:
|
|
74
|
+
self._serials.add(event.serial)
|
|
75
|
+
if event.side == "app" and event.leaf in QUIET_APP:
|
|
76
|
+
return
|
|
77
|
+
record = {
|
|
78
|
+
"t": round(event.at, 3),
|
|
79
|
+
"from": "us" if event.echo else event.side,
|
|
80
|
+
"leaf": event.leaf,
|
|
81
|
+
"value": _cut(event.value),
|
|
82
|
+
}
|
|
83
|
+
if event.side == "device" and event.leaf == "DeviceMSG" and isinstance(event.value, dict):
|
|
84
|
+
flat = codec.flatten(event.value)
|
|
85
|
+
story = tuple(flat.get(key) for key in STORY_KEYS)
|
|
86
|
+
record["value"] = dict(zip(STORY_KEYS, story, strict=True))
|
|
87
|
+
if story == self._story:
|
|
88
|
+
self._held = record
|
|
89
|
+
return
|
|
90
|
+
if self._held is not None:
|
|
91
|
+
self._records.append(self._held)
|
|
92
|
+
self._story, self._held = story, None
|
|
93
|
+
elif event.side == "device" and event.leaf != "data_feedback":
|
|
94
|
+
last = self._last_thin.get(event.leaf)
|
|
95
|
+
if last is not None and event.at - last < THIN_EVERY:
|
|
96
|
+
return
|
|
97
|
+
self._last_thin[event.leaf] = event.at
|
|
98
|
+
self._records.append(record)
|
|
99
|
+
while self._records and event.at - self._records[0]["t"] > self._max_age:
|
|
100
|
+
self._records.popleft()
|
|
101
|
+
|
|
102
|
+
def dump(self) -> list[dict[str, Any]]:
|
|
103
|
+
"""The recent past, oldest first, with serials, positions and identifiers redacted."""
|
|
104
|
+
redactor = Redactor()
|
|
105
|
+
for serial in self._serials:
|
|
106
|
+
redactor.register_serial(serial)
|
|
107
|
+
records = list(self._records)
|
|
108
|
+
if self._held is not None:
|
|
109
|
+
records.append(self._held)
|
|
110
|
+
return [redactor.redact_value(record) for record in sorted(records, key=lambda r: r["t"])]
|
yarbo_local/capture.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Sniffer: subscribe to the robot's broker and write everything to JSONL.
|
|
2
|
+
|
|
3
|
+
One record per message::
|
|
4
|
+
|
|
5
|
+
{"t": 1757260000.123, "ts": "2026-09-07T14:26:40.123Z",
|
|
6
|
+
"topic": "snowbot/SN/device/DeviceMSG", "bytes": 2310, "enc": "zlib",
|
|
7
|
+
"payload": {...}}
|
|
8
|
+
|
|
9
|
+
Both sides of the conversation are captured (``snowbot/+/#``) so that
|
|
10
|
+
commands published by the phone app are recorded next to the robot's replies.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
from collections import Counter
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import UTC, datetime
|
|
19
|
+
import json
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
import secrets
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
from typing import Any, TextIO
|
|
25
|
+
|
|
26
|
+
import aiomqtt
|
|
27
|
+
|
|
28
|
+
from . import codec, topics
|
|
29
|
+
from .redact import Redactor
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class CaptureStats:
|
|
34
|
+
"""Running counters printed at the end of a capture."""
|
|
35
|
+
|
|
36
|
+
started: float = field(default_factory=time.monotonic)
|
|
37
|
+
messages: int = 0
|
|
38
|
+
bytes_total: int = 0
|
|
39
|
+
per_topic: Counter[str] = field(default_factory=Counter)
|
|
40
|
+
encodings: Counter[str] = field(default_factory=Counter)
|
|
41
|
+
serials: set[str] = field(default_factory=set)
|
|
42
|
+
first_seen: dict[str, float] = field(default_factory=dict)
|
|
43
|
+
last_seen: dict[str, float] = field(default_factory=dict)
|
|
44
|
+
|
|
45
|
+
def note(self, topic: str, size: int, enc: str, now: float) -> None:
|
|
46
|
+
self.messages += 1
|
|
47
|
+
self.bytes_total += size
|
|
48
|
+
self.per_topic[topic] += 1
|
|
49
|
+
self.encodings[enc] += 1
|
|
50
|
+
self.first_seen.setdefault(topic, now)
|
|
51
|
+
self.last_seen[topic] = now
|
|
52
|
+
parsed = topics.parse(topic)
|
|
53
|
+
if parsed:
|
|
54
|
+
self.serials.add(parsed.serial)
|
|
55
|
+
|
|
56
|
+
def render(self) -> str:
|
|
57
|
+
elapsed = max(time.monotonic() - self.started, 1e-6)
|
|
58
|
+
lines = [
|
|
59
|
+
(
|
|
60
|
+
f"messages={self.messages} bytes={self.bytes_total} elapsed={elapsed:.1f}s "
|
|
61
|
+
f"encodings={dict(self.encodings)} serials={sorted(self.serials) or '-'}"
|
|
62
|
+
),
|
|
63
|
+
f"{'topic':60} {'count':>7} {'rate/s':>8} {'span s':>8}",
|
|
64
|
+
]
|
|
65
|
+
for topic, count in self.per_topic.most_common():
|
|
66
|
+
span = self.last_seen[topic] - self.first_seen[topic]
|
|
67
|
+
rate = count / span if span > 0 else 0.0
|
|
68
|
+
lines.append(f"{topic:60} {count:7d} {rate:8.2f} {span:8.1f}")
|
|
69
|
+
return "\n".join(lines)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_record(
|
|
73
|
+
topic: str, raw: bytes, redactor: Redactor | None, *, at: float | None = None
|
|
74
|
+
) -> dict[str, Any]:
|
|
75
|
+
"""One JSONL record for a raw message, redacted when a redactor is given."""
|
|
76
|
+
now = time.time() if at is None else at
|
|
77
|
+
payload, enc = codec.decode(raw)
|
|
78
|
+
if redactor is not None:
|
|
79
|
+
parsed = topics.parse(topic)
|
|
80
|
+
if parsed:
|
|
81
|
+
redactor.register_serial(parsed.serial)
|
|
82
|
+
topic = redactor.redact_topic(topic)
|
|
83
|
+
payload = redactor.redact_value(payload)
|
|
84
|
+
return {
|
|
85
|
+
"t": round(now, 3),
|
|
86
|
+
"ts": datetime.fromtimestamp(now, tz=UTC).isoformat(timespec="milliseconds"),
|
|
87
|
+
"topic": topic,
|
|
88
|
+
"bytes": len(raw),
|
|
89
|
+
"enc": enc,
|
|
90
|
+
"payload": payload,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def sniff(
|
|
95
|
+
host: str,
|
|
96
|
+
port: int = 1883,
|
|
97
|
+
*,
|
|
98
|
+
out: Path | None = None,
|
|
99
|
+
duration: float | None = None,
|
|
100
|
+
topic_filter: str | None = None,
|
|
101
|
+
redact: bool = False,
|
|
102
|
+
echo: TextIO | None = None,
|
|
103
|
+
stats_every: float = 15.0,
|
|
104
|
+
) -> CaptureStats:
|
|
105
|
+
"""Capture until ``duration`` seconds elapse or the process is interrupted."""
|
|
106
|
+
stats = CaptureStats()
|
|
107
|
+
redactor = Redactor() if redact else None
|
|
108
|
+
sub = topic_filter or topics.all_for(None)
|
|
109
|
+
log = echo or sys.stderr
|
|
110
|
+
fh = out.open("a", encoding="utf-8") if out else None
|
|
111
|
+
identifier = f"yarbo-local-sniff-{secrets.token_hex(3)}"
|
|
112
|
+
|
|
113
|
+
async def _run() -> None:
|
|
114
|
+
async with aiomqtt.Client(host, port=port, identifier=identifier) as client:
|
|
115
|
+
await client.subscribe(sub)
|
|
116
|
+
print(f"connected to {host}:{port}, subscribed {sub}", file=log)
|
|
117
|
+
next_stats = time.monotonic() + stats_every
|
|
118
|
+
async for message in client.messages:
|
|
119
|
+
raw = message.payload if isinstance(message.payload, bytes) else b""
|
|
120
|
+
rec = build_record(message.topic.value, raw, redactor)
|
|
121
|
+
stats.note(message.topic.value, rec["bytes"], rec["enc"], time.monotonic())
|
|
122
|
+
if fh:
|
|
123
|
+
fh.write(json.dumps(rec, separators=(",", ":")) + "\n")
|
|
124
|
+
fh.flush()
|
|
125
|
+
if stats_every and time.monotonic() >= next_stats:
|
|
126
|
+
print(stats.render(), file=log)
|
|
127
|
+
next_stats = time.monotonic() + stats_every
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
if duration:
|
|
131
|
+
async with asyncio.timeout(duration):
|
|
132
|
+
await _run()
|
|
133
|
+
else:
|
|
134
|
+
await _run()
|
|
135
|
+
except TimeoutError:
|
|
136
|
+
pass
|
|
137
|
+
finally:
|
|
138
|
+
if fh:
|
|
139
|
+
fh.close()
|
|
140
|
+
return stats
|
yarbo_local/cli.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Command line: ``yarbo-local sniff|probe|discover|dump|status|sim|studio|sitecheck``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from . import (
|
|
12
|
+
__version__,
|
|
13
|
+
capture,
|
|
14
|
+
discover,
|
|
15
|
+
dump,
|
|
16
|
+
probe,
|
|
17
|
+
redact,
|
|
18
|
+
simulator,
|
|
19
|
+
sitecheck,
|
|
20
|
+
status,
|
|
21
|
+
)
|
|
22
|
+
from .exceptions import YarboError
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
26
|
+
parser = argparse.ArgumentParser(
|
|
27
|
+
prog="yarbo-local",
|
|
28
|
+
description="Phase 0 tooling for the Yarbo local MQTT protocol. LAN only, no cloud.",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
31
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
32
|
+
|
|
33
|
+
p = sub.add_parser("sniff", help="capture every topic from a broker to JSONL")
|
|
34
|
+
p.add_argument("host")
|
|
35
|
+
p.add_argument("--port", type=int, default=1883)
|
|
36
|
+
p.add_argument("--out", type=Path, help="JSONL file to append to")
|
|
37
|
+
p.add_argument("--duration", type=float, help="stop after N seconds (default: until Ctrl-C)")
|
|
38
|
+
p.add_argument("--filter", help="MQTT subscription filter (default: snowbot/+/#)")
|
|
39
|
+
p.add_argument("--redact", action="store_true", help="redact serial, MAC, IP, coordinates")
|
|
40
|
+
p.add_argument("--stats-every", type=float, default=15.0)
|
|
41
|
+
p.add_argument(
|
|
42
|
+
"--fallback-scan",
|
|
43
|
+
metavar="CIDR",
|
|
44
|
+
help="if the host does not answer, scan this subnet for a broker and use the first hit",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
p = sub.add_parser("probe", help="send one allowlisted command and show what comes back")
|
|
48
|
+
p.add_argument("host")
|
|
49
|
+
p.add_argument("command", choices=sorted(probe.PHASE0_COMMANDS))
|
|
50
|
+
p.add_argument("--serial", help="robot serial (learned from traffic when omitted)")
|
|
51
|
+
p.add_argument("--port", type=int, default=1883)
|
|
52
|
+
p.add_argument("--payload", help="JSON object overriding the default payload")
|
|
53
|
+
p.add_argument("--encoding", choices=["auto", "zlib", "json"], default="auto")
|
|
54
|
+
p.add_argument(
|
|
55
|
+
"--settle",
|
|
56
|
+
type=float,
|
|
57
|
+
default=6.0,
|
|
58
|
+
help="seconds to observe before sending; asleep heartbeats arrive every ~5 s",
|
|
59
|
+
)
|
|
60
|
+
p.add_argument("--timeout", type=float, default=8.0, help="seconds to collect after sending")
|
|
61
|
+
p.add_argument("--out", type=Path, help="append the whole probe window to this JSONL")
|
|
62
|
+
p.add_argument(
|
|
63
|
+
"--fallback-scan",
|
|
64
|
+
metavar="CIDR",
|
|
65
|
+
help="if the host does not answer, scan this subnet for a broker with this serial",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
p = sub.add_parser("discover", help="scan hosts or a CIDR for brokers carrying snowbot traffic")
|
|
69
|
+
p.add_argument("hosts", help="host, comma list, or CIDR such as 192.168.40.0/24")
|
|
70
|
+
p.add_argument("--port", type=int, default=1883)
|
|
71
|
+
p.add_argument("--wait", type=float, default=6.0, help="seconds to listen per open host")
|
|
72
|
+
p.add_argument("--connect-timeout", type=float, default=0.6)
|
|
73
|
+
|
|
74
|
+
p = sub.add_parser("redact", help="re-redact an existing JSONL capture into a fixture")
|
|
75
|
+
p.add_argument("src", type=Path)
|
|
76
|
+
p.add_argument("dst", type=Path)
|
|
77
|
+
p.add_argument("--salt", help="shared salt so several files from one robot get the same tokens")
|
|
78
|
+
|
|
79
|
+
p = sub.add_parser("status", help="connect through the library and print the robot state")
|
|
80
|
+
p.add_argument("host")
|
|
81
|
+
p.add_argument("--serial", help="robot serial (learned from traffic when omitted)")
|
|
82
|
+
p.add_argument("--port", type=int, default=1883)
|
|
83
|
+
p.add_argument("--tls", action="store_true", help="use the TLS listener (usually 8883)")
|
|
84
|
+
p.add_argument("--wake", action="store_true", help="send the wake command first")
|
|
85
|
+
p.add_argument("--watch", type=float, default=0.0, help="print state changes for N seconds")
|
|
86
|
+
p.add_argument(
|
|
87
|
+
"--fallback-scan",
|
|
88
|
+
metavar="CIDR",
|
|
89
|
+
help="if the host does not answer, scan this subnet for a broker with this serial",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
p = sub.add_parser("sim", help="serve a simulated robot from a fixture on an MQTT broker")
|
|
93
|
+
p.add_argument("fixture", type=Path, help="a get_device_msg fixture, see protocol/fixtures")
|
|
94
|
+
p.add_argument("--broker", default="127.0.0.1")
|
|
95
|
+
p.add_argument("--port", type=int, default=1883)
|
|
96
|
+
p.add_argument("--rate", type=float, default=1.0, help="DeviceMSG frames per second awake")
|
|
97
|
+
|
|
98
|
+
p = sub.add_parser(
|
|
99
|
+
"studio", help="local web UI: live stream, knowledge diff, fixtures, command console"
|
|
100
|
+
)
|
|
101
|
+
p.add_argument("host")
|
|
102
|
+
p.add_argument("--serial", help="robot serial (learned from traffic when omitted)")
|
|
103
|
+
p.add_argument("--port", type=int, default=1883)
|
|
104
|
+
p.add_argument("--ui-host", default="127.0.0.1", help="bind address for the web UI")
|
|
105
|
+
p.add_argument("--ui-port", type=int, default=8765)
|
|
106
|
+
p.add_argument(
|
|
107
|
+
"--protocol-dir",
|
|
108
|
+
type=Path,
|
|
109
|
+
help="a checkout's protocol/ directory to write fixtures and field semantics into",
|
|
110
|
+
)
|
|
111
|
+
p.add_argument("--no-open", action="store_true", help="do not open a browser")
|
|
112
|
+
p.add_argument(
|
|
113
|
+
"--fallback-scan",
|
|
114
|
+
metavar="CIDR",
|
|
115
|
+
help="if the host does not answer, scan this subnet for a broker with this serial",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
p = sub.add_parser(
|
|
119
|
+
"sitecheck",
|
|
120
|
+
help="check a site with one or several robots and write a report that is safe to share",
|
|
121
|
+
)
|
|
122
|
+
p.add_argument("hosts", help="a subnet such as 192.168.50.0/24, an IP, or a comma list")
|
|
123
|
+
p.add_argument("--port", type=int, default=1883)
|
|
124
|
+
p.add_argument("--window", type=float, default=12.0, help="seconds to listen to each broker")
|
|
125
|
+
p.add_argument("--out", type=Path, default=Path(), help="directory for the report and log")
|
|
126
|
+
|
|
127
|
+
p = sub.add_parser("dump", help="summarise a JSONL capture")
|
|
128
|
+
p.add_argument("file", type=Path)
|
|
129
|
+
p.add_argument("--keys", action="store_true", help="print the DeviceMSG key inventory")
|
|
130
|
+
p.add_argument("--app", action="store_true", help="print app-side commands observed")
|
|
131
|
+
return parser
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _resolve_host(host: str, port: int, fallback: str | None, serial: str | None) -> str:
|
|
135
|
+
"""Return ``host`` if its broker port answers, else the first scan hit (matching serial)."""
|
|
136
|
+
if not fallback:
|
|
137
|
+
return host
|
|
138
|
+
if asyncio.run(discover.tcp_open(host, port, 1.5)):
|
|
139
|
+
return host
|
|
140
|
+
print(f"{host}:{port} not answering; scanning {fallback}", file=sys.stderr)
|
|
141
|
+
hits = asyncio.run(
|
|
142
|
+
discover.discover(
|
|
143
|
+
discover.expand(fallback), port=port, wait=6.0, heartbeat_only=True, names=False
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
for hit in hits:
|
|
147
|
+
if hit.serials and (serial is None or serial in hit.serials):
|
|
148
|
+
print(f"using {hit.host} ({hit.serials})", file=sys.stderr)
|
|
149
|
+
return hit.host
|
|
150
|
+
raise RuntimeError(f"no broker found on {fallback}")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def main(argv: list[str] | None = None) -> int:
|
|
154
|
+
args = _build_parser().parse_args(argv)
|
|
155
|
+
try:
|
|
156
|
+
if getattr(args, "fallback_scan", None):
|
|
157
|
+
args.host = _resolve_host(
|
|
158
|
+
args.host, args.port, args.fallback_scan, getattr(args, "serial", None)
|
|
159
|
+
)
|
|
160
|
+
if args.cmd == "sniff":
|
|
161
|
+
stats = asyncio.run(
|
|
162
|
+
capture.sniff(
|
|
163
|
+
args.host,
|
|
164
|
+
args.port,
|
|
165
|
+
out=args.out,
|
|
166
|
+
duration=args.duration,
|
|
167
|
+
topic_filter=args.filter,
|
|
168
|
+
redact=args.redact,
|
|
169
|
+
stats_every=args.stats_every,
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
print(stats.render(), file=sys.stderr)
|
|
173
|
+
elif args.cmd == "probe":
|
|
174
|
+
payload = json.loads(args.payload) if args.payload else None
|
|
175
|
+
result = asyncio.run(
|
|
176
|
+
probe.probe(
|
|
177
|
+
args.host,
|
|
178
|
+
args.command,
|
|
179
|
+
serial=args.serial,
|
|
180
|
+
port=args.port,
|
|
181
|
+
payload=payload,
|
|
182
|
+
encoding=args.encoding,
|
|
183
|
+
settle=args.settle,
|
|
184
|
+
timeout=args.timeout,
|
|
185
|
+
out=args.out,
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
print(result.render())
|
|
189
|
+
elif args.cmd == "discover":
|
|
190
|
+
hits = asyncio.run(
|
|
191
|
+
discover.discover(
|
|
192
|
+
discover.expand(args.hosts),
|
|
193
|
+
port=args.port,
|
|
194
|
+
wait=args.wait,
|
|
195
|
+
connect_timeout=args.connect_timeout,
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
if not hits:
|
|
199
|
+
print("no hosts with an open MQTT port")
|
|
200
|
+
for hit in hits:
|
|
201
|
+
print(hit.render())
|
|
202
|
+
elif args.cmd == "redact":
|
|
203
|
+
count = redact.redact_file(args.src, args.dst, salt=args.salt)
|
|
204
|
+
print(f"wrote {count} records to {args.dst}", file=sys.stderr)
|
|
205
|
+
elif args.cmd == "status":
|
|
206
|
+
return asyncio.run(
|
|
207
|
+
status.show(
|
|
208
|
+
args.host,
|
|
209
|
+
port=args.port,
|
|
210
|
+
tls=args.tls,
|
|
211
|
+
serial=args.serial,
|
|
212
|
+
wake=args.wake,
|
|
213
|
+
watch=args.watch,
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
elif args.cmd == "sim":
|
|
217
|
+
sim = simulator.Simulator.from_fixture(args.fixture)
|
|
218
|
+
print(
|
|
219
|
+
f"simulating {sim.serial} firmware {sim.firmware} on {args.broker}:{args.port}",
|
|
220
|
+
file=sys.stderr,
|
|
221
|
+
)
|
|
222
|
+
asyncio.run(simulator.run_on_broker(sim, args.broker, args.port, rate=args.rate))
|
|
223
|
+
elif args.cmd == "studio":
|
|
224
|
+
try:
|
|
225
|
+
from .studio.server import serve # noqa: PLC0415 - optional extra
|
|
226
|
+
except ImportError:
|
|
227
|
+
print(
|
|
228
|
+
"the Studio needs aiohttp: pip install 'yarbo-local[studio]'", file=sys.stderr
|
|
229
|
+
)
|
|
230
|
+
return 1
|
|
231
|
+
asyncio.run(
|
|
232
|
+
serve(
|
|
233
|
+
args.host,
|
|
234
|
+
robot_port=args.port,
|
|
235
|
+
serial=args.serial,
|
|
236
|
+
ui_host=args.ui_host,
|
|
237
|
+
ui_port=args.ui_port,
|
|
238
|
+
protocol_dir=args.protocol_dir,
|
|
239
|
+
open_browser=not args.no_open,
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
elif args.cmd == "sitecheck":
|
|
243
|
+
survey, findings = sitecheck.run(
|
|
244
|
+
args.hosts, port=args.port, window=args.window, out_dir=args.out
|
|
245
|
+
)
|
|
246
|
+
for finding in findings:
|
|
247
|
+
print(survey.labels.scrub(finding.render()), end="\n\n")
|
|
248
|
+
print(
|
|
249
|
+
f"report: {args.out / 'sitecheck-report.md'}\nlog: {args.out / 'sitecheck.log'}"
|
|
250
|
+
)
|
|
251
|
+
print("Both files use labels in place of serials and addresses, and hold no position.")
|
|
252
|
+
return 0 if all(f.ok for f in findings) else 1
|
|
253
|
+
elif args.cmd == "dump":
|
|
254
|
+
print(dump.summarise(args.file).render(keys=args.keys, app=args.app))
|
|
255
|
+
except KeyboardInterrupt:
|
|
256
|
+
return 130
|
|
257
|
+
except (ValueError, RuntimeError, OSError, YarboError) as err:
|
|
258
|
+
print(f"error: {err}", file=sys.stderr)
|
|
259
|
+
return 1
|
|
260
|
+
return 0
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
if __name__ == "__main__":
|
|
264
|
+
sys.exit(main())
|