Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
08edc3e
dotbot/sdk: add value types (Position, events, link budget)
geonnave Jun 2, 2026
a2b5524
dotbot/sdk: add Bot, Action and the http connection backend
geonnave Jun 2, 2026
597f63b
dotbot/sdk: add Swarm and Fleet, connect() over http
geonnave Jun 2, 2026
31d3eec
dotbot/examples: add labyrinth rewritten on the Swarm SDK
geonnave Jun 2, 2026
666b29b
dotbot/sdk: add swarm events and a rate-clamped positions stream
geonnave Jun 2, 2026
334f7ed
dotbot/examples: add an sdk_demo guided tour of the Swarm SDK
geonnave Jun 2, 2026
c4a8c73
dotbot/sdk: add swarm.tick() loop and fire-and-forget Bot.goto()
geonnave Jun 2, 2026
89a07ba
dotbot/examples: add charging_station on the Swarm SDK
geonnave Jun 2, 2026
57c2b3e
dotbot/sdk: flush pending commands on close instead of cancelling
geonnave Jun 2, 2026
583f9e1
dotbot/examples: drop the uncontrolled disengage from charging_statio…
geonnave Jun 2, 2026
5518d32
dotbot/examples: add a sdk/ folder of simple SDK examples
geonnave Jun 2, 2026
dd7bca8
dotbot/patterns: add fleet placement layouts (grid/circle/line/random)
geonnave Jun 2, 2026
11db42a
dotbot/simulator: spawn a generated fleet with --bots and --layout
geonnave Jun 2, 2026
f65ffc1
dotbot/simulator: fix generated bots ignoring commands
geonnave Jun 2, 2026
9b2521e
dotbot/examples: add charge+park phases to charging_station_sdk
geonnave Jun 5, 2026
a30d2e7
dotbot/sdk: make move_to/follow bounded and shutdown-safe
geonnave Jun 5, 2026
31ee22b
dotbot/sdk: emit BotJoined when a bot joins via fleet reload
geonnave Jun 5, 2026
b781206
dotbot/sdk: accept run --port without --host
geonnave Jun 5, 2026
922e8c5
dotbot/examples: add work_and_charge on the Swarm SDK
geonnave Jun 6, 2026
e3e76b9
dotbot/examples: add minimum_naming_game on the Swarm SDK
geonnave Jun 6, 2026
7b40451
dotbot/patterns: keep random bots apart and fill rectangular arenas
geonnave Jun 9, 2026
90c5769
dotbot/simulator: size generated --bots layouts to --map-size
geonnave Jun 9, 2026
82e687a
dotbot/simulator: give random-layout bots random headings
geonnave Jun 9, 2026
fde0ea5
dotbot/rest: send rgb_led with the bot's application
geonnave Jun 9, 2026
6c91d6c
dotbot/examples: add sdk_demo LED and motion demos
geonnave Jun 9, 2026
7038e23
dotbot/sdk: pace downlink commands to the gateway budget
geonnave Jun 9, 2026
1967fdc
dotbot/rest: tolerate transient send failures
geonnave Jun 9, 2026
2d5d9bd
dotbot/examples: add wiggle and ripple_pulse sdk_demo demos
geonnave Jun 9, 2026
d99cc50
dotbot/examples: replace ripple_pulse with in-place ripple_wiggle
geonnave Jun 9, 2026
4bb6449
dotbot/examples: add distribute sdk_demo demo
geonnave Jun 9, 2026
cae0067
dotbot/examples: add collision-free disperse sdk_demo demo
geonnave Jun 9, 2026
f7f9085
dotbot/sdk: expose the controller arena via swarm.map_size()
geonnave Jun 10, 2026
c3ecde4
dotbot/examples: drive sdk_demo motions through buffered Voronoi cells
geonnave Jun 10, 2026
537e806
dotbot/sdk: add buffered-Voronoi-cell collision avoidance
geonnave Jun 10, 2026
f727d93
dotbot/examples: reuse the sdk avoidance geometry in sdk_demo
geonnave Jun 10, 2026
d5535e1
dotbot/sdk: tolerate degenerate clip edges from duplicate positions
geonnave Jun 10, 2026
ed80b14
dotbot/sdk: gate glitchy LH2 fixes and stop contact-pinned bots
geonnave Jun 10, 2026
b690945
dotbot/examples: add contact stop and offline skip to sdk_demo drive
geonnave Jun 10, 2026
9c20b6f
dotbot/controller: serialize websocket sends per client connection
geonnave Jun 10, 2026
a543f29
dotbot/swarm: rename the sdk subpackage to swarm
geonnave Jun 11, 2026
633bbde
dotbot/swarm: fix stop override, shepherd races, event semantics
geonnave Jun 11, 2026
5452942
dotbot/controller: keep websocket send locks until disconnect
geonnave Jun 11, 2026
3a8049a
dotbot/rest: raise instead of crashing when map size fetch fails
geonnave Jun 11, 2026
9df0f42
dotbot: apply black and ruff to the swarm sdk and demos
geonnave Jun 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion dotbot/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,12 +287,25 @@ class DotBotSimulatorAdapter(SimulatorAdapterBase):
def __init__(
self,
simulator_init_state: str = SIMULATOR_INIT_STATE_DEFAULT,
bots: int | None = None,
layout: str = "grid",
seed: int = 0,
map_size: str | None = None,
):
self.simulator_init_state = simulator_init_state
self.bots = bots
self.layout = layout
self.seed = seed
self.map_size = map_size

def create_simulator(self, on_frame_received: callable):
return DotBotSimulatorCommunicationInterface(
on_frame_received, self.simulator_init_state
on_frame_received,
self.simulator_init_state,
bots=self.bots,
layout=self.layout,
seed=self.seed,
map_size=self.map_size,
)


Expand Down
10 changes: 10 additions & 0 deletions dotbot/cli/swarm.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,16 @@ def _with_config_injection(swarmit_group):
`--help` and subcommand help flow straight through.
"""

# Bridge to the Python rung: `dotbot swarm` operates the fleet; *driving*
# it (motion, LEDs, positions) is the Swarm API's job. Say so where an
# operator looks first.
if not swarmit_group.epilog:
swarmit_group.epilog = (
"To drive the swarm from Python (motion, LEDs, positions), start a "
"controller (`dotbot run controller`) and use "
"`from dotbot.swarm import Swarm`."
)

@click.command(
name="swarm",
help=_HELP,
Expand Down
39 changes: 30 additions & 9 deletions dotbot/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,7 @@
from typing import Dict, List, Optional

import serial
import starlette
import uvicorn
import websockets
from dotbot_utils.protocol import Frame, Payload
from dotbot_utils.serial_interface import SerialInterfaceException
from fastapi import WebSocket
Expand Down Expand Up @@ -135,6 +133,9 @@ class ControllerSettings:
log_output: str = os.path.join(os.getcwd(), "pydotbot.log")
csv_data_output: Optional[str] = None
simulator_init_state: str = SIMULATOR_INIT_STATE_DEFAULT
simulator_bots: Optional[int] = None
simulator_layout: str = "grid"
simulator_seed: int = 0


def lh2_distance(last: DotBotLH2Position, new: DotBotLH2Position) -> float:
Expand Down Expand Up @@ -197,6 +198,7 @@ def __init__(self, settings: ControllerSettings):
self.settings = settings
self.adapter: GatewayAdapterBase = None
self.websockets = []
self._ws_send_locks = {}
self.lh2_calibration: list[CalibrationHomography] = load_calibration()
self.api = api
self.map_size = DotBotMapSizeModel(
Expand Down Expand Up @@ -559,20 +561,35 @@ def handle_received_frame(
asyncio.create_task(self.notify_clients(notification))

async def _ws_send_safe(self, websocket: WebSocket, msg: str):
"""Safely send a message to a websocket client."""
"""Safely send a message to a websocket client.

Writes to one connection are serialized with a per-connection lock:
notify_clients tasks run concurrently (one per received frame), and
two coroutines writing/draining the same websocket trips an
AssertionError deep in the websockets protocol, which crashed the
controller on a busy real fleet. The broad except is deliberate for
the same reason - a failing client gets dropped, never the controller.
"""
lock = self._ws_send_locks.setdefault(id(websocket), asyncio.Lock())
try:
await websocket.send_text(msg)
except (
websockets.exceptions.ConnectionClosedError,
RuntimeError,
starlette.websockets.WebSocketDisconnect,
) as exc:
async with lock:
if websocket not in self.websockets:
# Dropped by a concurrent sender while we waited. Sends are
# membership-gated, so popping the entry here is safe even
# with senders still queued on the old lock object.
self._ws_send_locks.pop(id(websocket), None)
return
await websocket.send_text(msg)
except Exception as exc: # noqa: BLE001
self.logger.warning(
"Failed to send message to websocket client",
error=str(exc),
)
if websocket in self.websockets:
self.websockets.remove(websocket)
# The lock entry is NOT popped here: senders already queued on it
# must keep serializing through the same object. It is cleaned up
# on the websocket-disconnect path instead.

async def notify_clients(self, notification):
"""Send a message to all clients connected."""
Expand Down Expand Up @@ -699,6 +716,10 @@ async def _start_adapter(self):
elif self.settings.adapter == "dotbot-simulator":
self.adapter = DotBotSimulatorAdapter(
self.settings.simulator_init_state,
bots=self.settings.simulator_bots,
layout=self.settings.simulator_layout,
seed=self.settings.simulator_seed,
map_size=self.settings.map_size,
)
elif self.settings.adapter == "sailbot-simulator":
self.adapter = SailBotSimulatorAdapter()
Expand Down
32 changes: 31 additions & 1 deletion dotbot/controller_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,27 @@ def _maybe_scaffold_sim_state(explicit_init_state):
type=click.Path(dir_okay=False),
help=f"Path to the simulator initial state .toml file. Defaults to '{SIMULATOR_INIT_STATE_DEFAULT}'.",
)
@click.option(
"--bots",
"simulator_bots",
type=click.IntRange(1, 100),
default=None,
help="Simulator: spawn this many bots (1-100) in a generated --layout, instead of an init-state file.",
)
@click.option(
"--layout",
"simulator_layout",
type=click.Choice(["grid", "circle", "line", "random"]),
default=None,
help="Simulator: how --bots are arranged (default: grid).",
)
@click.option(
"--seed",
"simulator_seed",
type=int,
default=None,
help="Simulator: random seed for `--layout random`.",
)
@click.pass_context
def main(
ctx,
Expand All @@ -237,6 +258,9 @@ def main(
map_size,
background_map,
simulator_init_state,
simulator_bots,
simulator_layout,
simulator_seed,
headless,
verbose,
log_level,
Expand Down Expand Up @@ -285,7 +309,10 @@ def main(
# None, so fold in any config value), offer to scaffold an editable
# world file in the cwd. resolve_init_state_path then picks up the
# freshly-written file (or the packaged world if declined/non-tty).
if conn_settings.get("adapter", "").endswith("simulator"):
if (
conn_settings.get("adapter", "").endswith("simulator")
and simulator_bots is None
):
_maybe_scaffold_sim_state(
simulator_init_state or file_data.get("simulator_init_state")
)
Expand All @@ -296,6 +323,9 @@ def main(
"map_size": map_size,
"background_map": background_map,
"simulator_init_state": simulator_init_state,
"simulator_bots": simulator_bots,
"simulator_layout": simulator_layout,
"simulator_seed": simulator_seed,
"headless": True if headless else None,
"verbose": verbose,
"log_level": log_level,
Expand Down
68 changes: 61 additions & 7 deletions dotbot/dotbot_simulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,44 @@ class InitStateToml(BaseModel):
network: SimulatedNetworkSettings = SimulatedNetworkSettings()


def _parse_map_size(map_size: str | None) -> tuple[int, int] | None:
"""Parse a 'WIDTHxHEIGHT' map size (in mm) into (width, height), or None if
it can't be parsed (so the layout falls back to its default arena)."""
if not map_size:
return None
try:
width, height = map_size.lower().split("x")
return int(width), int(height)
except (ValueError, AttributeError):
return None


def generate_fleet(
n: int, layout: str = "grid", seed: int = 0, map_size: str | None = None
) -> List[SimulatedDotBotSettings]:
"""Build `n` simulated bots placed in a named layout, sized to `map_size`
(so the fleet fills the whole map), with sequential auto-generated
addresses. The `random` layout also gives each bot a random heading; the
structured layouts keep the default heading so rows/circles stay aligned.
Backs `dotbot run simulator --bots N --layout`."""
from dotbot import patterns

dims = _parse_map_size(map_size)
kwargs = {"width": dims[0], "height": dims[1]} if dims else {}
positions = patterns.layout(n, layout, seed=seed, **kwargs)
# Separate seeded stream so headings don't perturb the placement.
heading_rng = random.Random(seed + 7919) if layout == "random" else None
fleet = []
for i, (x, y) in enumerate(positions):
extra = {"direction": heading_rng.randrange(360)} if heading_rng else {}
fleet.append(
SimulatedDotBotSettings(
address=f"{i + 1:016x}", pos_x=int(x), pos_y=int(y), **extra
)
)
return fleet


class DotBotSimulator:
"""Simulator class for the dotbot."""

Expand All @@ -190,7 +228,10 @@ def __init__(self, settings: SimulatedDotBotSettings, tx_queue: queue.Queue):

self.pwm_left = 0
self.pwm_right = 0
self.direction = settings.direction
# Map the "unset direction" sentinel to north (0), same as theta above;
# otherwise -1000 reaches the control loop as a bogus heading and the
# bot can never settle on a waypoint.
self.direction = settings.direction if settings.direction != -1000 else 0

# Accumulated encoder deltas between control-loop calls (control runs at
# SIMULATOR_UPDATE_INTERVAL_S, physics at SIMULATOR_STEP_DELTA_T — multiple
Expand Down Expand Up @@ -604,7 +645,7 @@ def rx_frame(self):
if frame is None:
break
with self._lock:
if self.address == hex(frame.header.destination)[2:]:
if self.address == f"{frame.header.destination:016x}":
if frame.payload_type == PayloadType.CMD_MOVE_RAW:
self.controller_mode = ControlModeType.MANUAL
self.waypoint_index = 0
Expand Down Expand Up @@ -769,14 +810,27 @@ def resolve_init_state_path(path: str) -> str:
class DotBotSimulatorCommunicationInterface:
"""Bidirectional serial interface to control simulated robots"""

def __init__(self, on_frame_received: Callable, simulator_init_state: str):
def __init__(
self,
on_frame_received: Callable,
simulator_init_state: str,
bots: int | None = None,
layout: str = "grid",
seed: int = 0,
map_size: str | None = None,
):
self.queue = queue.Queue()
self.on_frame_received = on_frame_received
self._stp_event = threading.Event()
self.main_thread = threading.Thread(target=self.run, daemon=True)
init_state = InitStateToml(
**toml.load(resolve_init_state_path(simulator_init_state))
)
if bots:
init_state = InitStateToml(
dotbots=generate_fleet(bots, layout, seed, map_size)
)
else:
init_state = InitStateToml(
**toml.load(resolve_init_state_path(simulator_init_state))
)
self._network = init_state.network
self.dotbots = [
DotBotSimulator(
Expand Down Expand Up @@ -828,7 +882,7 @@ def _packet_delivered(self, pdr: int) -> bool:

def handle_dotbot_frame(self, frame):
"""Send bytes to the fake serial, similar to the real gateway."""
addr = hex(frame.header.source)[2:]
addr = f"{frame.header.source:016x}"
index = self._address_to_index.get(addr, 0)
if self._dotbot_modes[index] == SimulatedNetworkMode.MARI:
self._mari.schedule_uplink(frame, index)
Expand Down
26 changes: 26 additions & 0 deletions dotbot/events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# SPDX-FileCopyrightText: 2026-present Inria
#
# SPDX-License-Identifier: BSD-3-Clause

"""Public re-export of the SDK event types, so users write
`from dotbot.events import BotJoined`. The definitions live in
`dotbot.swarm.events`.
"""

from dotbot.swarm.events import ( # noqa: F401
BatteryUpdate,
BotJoined,
BotLeft,
Event,
ModeChanged,
PositionUpdate,
)

__all__ = [
"Event",
"BotJoined",
"BotLeft",
"PositionUpdate",
"BatteryUpdate",
"ModeChanged",
]
Loading
Loading