Skip to content

Commit 3948eed

Browse files
committed
dotbot/cli/gateway: add host-side Mari gateway bridge subcommand
Thin re-mount of marilib's mari-edge: bridges UART <-> MQTT. With no --mqtt-url, runs in local-stdout mode (prints received frames) so a freshly-flashed gateway can be checked with zero broker infra. AI-assisted: Claude Opus 4.7
1 parent 37ddede commit 3948eed

4 files changed

Lines changed: 153 additions & 1 deletion

File tree

‎dotbot/cli/gateway.py‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# SPDX-FileCopyrightText: 2026-present Inria
2+
# SPDX-License-Identifier: BSD-3-Clause
3+
4+
"""`dotbot gateway` — host-side Mari gateway bridge.
5+
6+
Runs on whatever computer the gateway firmware is plugged into (a
7+
laptop for a starter setup, a Pi for a permanent install). Bridges UART
8+
HDLC frames to/from an MQTT broker, so a `dotbot controller --conn
9+
mqtts://…` can reach the swarm from anywhere.
10+
11+
Thin re-mount of marilib's `mari-edge`: wraps a `MarilibEdge` with a
12+
serial adapter and (optionally) an MQTT adapter. With no `--mqtt-url`
13+
it runs in **local-stdout mode** — received frames print to stdout, so
14+
a freshly-flashed gateway can be sanity-checked with zero MQTT infra.
15+
16+
Phase 1 is a raw bridge (mari frames + raw mari topics). DotBot-semantic
17+
MQTT topics are a later phase, tracked in the controller-CLI-redesign
18+
plan.
19+
"""
20+
21+
import os
22+
import time
23+
24+
import click
25+
26+
27+
def _run_gateway(port, mqtt_url): # pragma: no cover - needs a real gateway
28+
"""Construct a MarilibEdge bridge and pump it until interrupted.
29+
30+
Imports marilib lazily so `dotbot gateway --help` is cheap and the
31+
command is importable without a serial port present.
32+
"""
33+
from marilib.communication_adapter import MQTTAdapter, SerialAdapter
34+
from marilib.marilib_edge import MarilibEdge
35+
from marilib.model import EdgeEvent
36+
from marilib.serial_uart import get_default_port
37+
38+
port = port or get_default_port()
39+
stdout_mode = mqtt_url is None
40+
41+
def on_event(event, event_data):
42+
# In local-stdout mode, surface received data frames so a fresh
43+
# gateway can be eyeballed without a broker.
44+
if stdout_mode and event == EdgeEvent.NODE_DATA:
45+
src = getattr(event_data.header, "source", 0)
46+
payload = getattr(event_data, "payload", b"")
47+
click.echo(f"<- {src:016x}: {bytes(payload).hex()}")
48+
49+
mqtt_interface = None
50+
if mqtt_url is not None:
51+
# Broker credentials come from the environment (see the controller
52+
# surface). They take effect once the marilib companion adds
53+
# username/password to MQTTAdapter; until then anonymous connect.
54+
mqtt_interface = MQTTAdapter.from_url(mqtt_url, is_edge=True)
55+
_ = (os.environ.get("DOTBOT_MQTT_USER"), os.environ.get("DOTBOT_MQTT_PASS"))
56+
57+
mari = MarilibEdge(
58+
on_event,
59+
serial_interface=SerialAdapter(port),
60+
mqtt_interface=mqtt_interface,
61+
)
62+
where = mqtt_url if mqtt_url else "local-stdout"
63+
click.echo(f"dotbot gateway: {port} <-> {where}", err=True)
64+
try:
65+
while True:
66+
mari.update()
67+
time.sleep(0.5)
68+
except KeyboardInterrupt:
69+
pass
70+
finally:
71+
try:
72+
mari.close()
73+
except Exception: # pylint: disable=broad-except
74+
pass
75+
76+
77+
@click.command(
78+
name="gateway",
79+
help=(
80+
"Host-side Mari gateway bridge (UART <-> MQTT). Runs wherever the "
81+
"gateway firmware is plugged in. Without --mqtt-url, prints received "
82+
"frames to stdout (local debug mode)."
83+
),
84+
)
85+
@click.option(
86+
"-p",
87+
"--port",
88+
type=str,
89+
default=None,
90+
help="Serial port of the attached gateway firmware. Default: autodetect.",
91+
)
92+
@click.option(
93+
"-m",
94+
"--mqtt-url",
95+
type=str,
96+
default=None,
97+
help=(
98+
"MQTT broker to bridge to (`mqtts://host:port`). Absent → "
99+
"local-stdout debug mode."
100+
),
101+
)
102+
def cmd(port, mqtt_url):
103+
"""Run the gateway bridge."""
104+
_run_gateway(port, mqtt_url)

‎dotbot/cli/main.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,12 @@
3838
(
3939
"sim",
4040
"dotbot.cli.sim",
41-
"Standalone simulator (equivalent to controller --adapter dotbot-simulator).",
41+
"Standalone simulator (equivalent to controller --conn simulator).",
42+
),
43+
(
44+
"gateway",
45+
"dotbot.cli.gateway",
46+
"Host-side Mari gateway bridge (UART <-> MQTT).",
4247
),
4348
(
4449
"swarm",

‎dotbot/tests/test_cli_dispatcher.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
EXPECTED_SUBCOMMANDS = {
4545
"controller",
4646
"sim",
47+
"gateway",
4748
"swarm",
4849
"calibrate-lh2",
4950
"demo",

‎dotbot/tests/test_gateway.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# SPDX-FileCopyrightText: 2026-present Inria
2+
# SPDX-License-Identifier: BSD-3-Clause
3+
4+
"""Tests for `dotbot gateway` — the CLI surface, not the live bridge.
5+
6+
The bridge itself (`_run_gateway`) needs a real serial gateway, so it's
7+
mocked here; we check flag parsing and that the command forwards
8+
`--port` / `--mqtt-url` correctly.
9+
"""
10+
11+
from unittest.mock import patch
12+
13+
from click.testing import CliRunner
14+
15+
from dotbot.cli.gateway import cmd as gateway_cmd
16+
17+
18+
def test_gateway_help_mentions_stdout_mode():
19+
result = CliRunner().invoke(gateway_cmd, ["--help"])
20+
assert result.exit_code == 0
21+
assert "--port" in result.output
22+
assert "--mqtt-url" in result.output
23+
# The no-broker default behaviour is documented.
24+
assert "stdout" in result.output.lower()
25+
26+
27+
@patch("dotbot.cli.gateway._run_gateway")
28+
def test_gateway_forwards_port_and_mqtt_url(run):
29+
result = CliRunner().invoke(
30+
gateway_cmd,
31+
["--port", "/dev/ttyACM0", "--mqtt-url", "mqtts://argus:8883"],
32+
)
33+
assert result.exit_code == 0, result.output
34+
run.assert_called_once_with("/dev/ttyACM0", "mqtts://argus:8883")
35+
36+
37+
@patch("dotbot.cli.gateway._run_gateway")
38+
def test_gateway_defaults_to_stdout_mode_no_mqtt(run):
39+
result = CliRunner().invoke(gateway_cmd, ["--port", "/dev/ttyACM0"])
40+
assert result.exit_code == 0, result.output
41+
# mqtt_url is None → local-stdout mode.
42+
run.assert_called_once_with("/dev/ttyACM0", None)

0 commit comments

Comments
 (0)