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.
Files changed (42) hide show
  1. yarbo_local/__init__.py +93 -0
  2. yarbo_local/blackbox.py +110 -0
  3. yarbo_local/capture.py +140 -0
  4. yarbo_local/cli.py +264 -0
  5. yarbo_local/client.py +231 -0
  6. yarbo_local/codec.py +140 -0
  7. yarbo_local/data/codes.yaml +93 -0
  8. yarbo_local/data/commands.yaml +680 -0
  9. yarbo_local/data/fields.yaml +4159 -0
  10. yarbo_local/discover.py +244 -0
  11. yarbo_local/dump.py +110 -0
  12. yarbo_local/exceptions.py +65 -0
  13. yarbo_local/feedback.py +177 -0
  14. yarbo_local/fieldmap.py +207 -0
  15. yarbo_local/lifecycle.py +325 -0
  16. yarbo_local/models.py +904 -0
  17. yarbo_local/observer.py +173 -0
  18. yarbo_local/obstacles.py +205 -0
  19. yarbo_local/preflight.py +100 -0
  20. yarbo_local/probe.py +234 -0
  21. yarbo_local/py.typed +0 -0
  22. yarbo_local/redact.py +260 -0
  23. yarbo_local/registry.py +184 -0
  24. yarbo_local/resolve.py +105 -0
  25. yarbo_local/session.py +434 -0
  26. yarbo_local/simulator.py +408 -0
  27. yarbo_local/sitecheck.py +841 -0
  28. yarbo_local/status.py +90 -0
  29. yarbo_local/studio/__init__.py +4 -0
  30. yarbo_local/studio/server.py +440 -0
  31. yarbo_local/studio/static/app.js +683 -0
  32. yarbo_local/studio/static/index.html +13 -0
  33. yarbo_local/studio/static/style.css +17 -0
  34. yarbo_local/studio/static/vendor/README.md +12 -0
  35. yarbo_local/studio/static/vendor/lit.js +44 -0
  36. yarbo_local/topics.py +84 -0
  37. yarbo_local/transport.py +264 -0
  38. yarbo_local-0.1.0.dev0.dist-info/METADATA +144 -0
  39. yarbo_local-0.1.0.dev0.dist-info/RECORD +42 -0
  40. yarbo_local-0.1.0.dev0.dist-info/WHEEL +4 -0
  41. yarbo_local-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  42. yarbo_local-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
@@ -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
+ ]
@@ -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())