From c66794385a46a3595501a41e4dd07038922f33c7 Mon Sep 17 00:00:00 2001 From: Clemens Elflein Date: Tue, 26 May 2026 03:26:50 +0200 Subject: [PATCH 01/11] Added examples, deadlock fix --- .idea/vcs.xml | 2 + xbot_service_interface/README.md | 103 ++ xbot_service_interface/examples/gpio.json | 24 + xbot_service_interface/examples/lcd_hello.py | 159 ++++ xbot_service_interface/pyproject.toml | 4 + xbot_service_interface/requirements.txt | 3 + .../xbot_service_interface/interface.py | 115 ++- .../xbot_service_interface/manager.py | 7 +- .../xbot_service_interface/shell.py | 890 ++++++++++++++++++ 9 files changed, 1260 insertions(+), 47 deletions(-) create mode 100644 xbot_service_interface/examples/gpio.json create mode 100644 xbot_service_interface/examples/lcd_hello.py create mode 100644 xbot_service_interface/xbot_service_interface/shell.py diff --git a/.idea/vcs.xml b/.idea/vcs.xml index 43e5888..fa5d3f7 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -2,6 +2,8 @@ + + diff --git a/xbot_service_interface/README.md b/xbot_service_interface/README.md index 39e254f..1dfb50a 100644 --- a/xbot_service_interface/README.md +++ b/xbot_service_interface/README.md @@ -6,7 +6,24 @@ Connect to xBot services running anywhere on your network — no code generation ## Installation ```bash +# Library only pip install xbot-service-interface + +# Library + interactive shell (IPython + Rich) +pip install "xbot-service-interface[shell]" +``` + +### From source + +```bash +git clone https://github.com/xtech/xbot_framework +cd xbot_framework/xbot_service_interface + +# Library only +pip install -e . + +# With interactive shell +pip install -e ".[shell]" ``` ## Quick start @@ -82,8 +99,94 @@ val = echo.registers['Prefix'] All registers are sent as a single configuration transaction (required by the xBot protocol). +## Interactive shell + +`xbot-shell` is an IPython-based REPL for exploring and testing services live. +Requires the `[shell]` extras (`ipython`, `rich`). + +```bash +xbot-shell # bind all interfaces +xbot-shell --bind 192.168.1.x # specific interface +``` + +On startup the shell listens for service advertisements on the multicast group +and prints each service as it appears: + +``` +→ Discovered: EchoService (id=1) at 192.168.1.5:4242 — connect(1) +``` + +### Shell commands + +```python +services() # list all discovered services +svc = connect(1) # connect by service ID +svc = connect("EchoService") # or by type name + +svc.wait_connected() # block until the service is claimed (default 10 s) +svc.info() # print schema — inputs, outputs, registers, RPC functions +svc.watch_all() # stream every output value to the console +svc.watch('echo') # stream a specific output +``` + +IPython magic equivalents: + +``` +%services +%connect EchoService +%connect 1 as svc +``` + +### Tab completion + +All service channels are tab-completable once connected: + +``` +svc.send_ → send_input_text(value) +svc.call_ → call_rpc_echo_test(text, echo_count) +svc.on_ → on_echo_changed, on_message_count_changed +svc.registers. → prefix, echo_count +``` + +Pressing `?` on a method shows the full parameter signature: + +```python +svc.call_rpc_echo_test? +# RpcEchoTest(text: char[10], echo_count: uint32_t) -> char[30] +``` + +### Example session + +```python +In [1]: services() +# ┌────┬─────────────┬──────────────────┬─────┬─────┬────┐ +# │ ID │ Type │ Endpoint │ In │ Out │ Fn │ +# ├────┼─────────────┼──────────────────┼─────┼─────┼────┤ +# │ 1 │ EchoService │ 192.168.1.5:4242 │ 1 │ 2 │ 3 │ +# └────┴─────────────┴──────────────────┴─────┴─────┴────┘ + +In [2]: svc = connect(1) +In [3]: svc.wait_connected() +# ✓ Connected to EchoService + +In [4]: svc.registers.prefix = "py: " +In [5]: svc.registers.echo_count = 2 + +In [6]: svc.watch_all() +# Watching: echo, message_count + +In [7]: svc.send_input_text("hello") +# 1234567ms echo = 'py: hello' +# 1234567ms message_count = 1 + +In [8]: svc.call_rpc_echo_test("hi", 3) +# 'py: hihihi' +``` + ## Requirements - Python 3.10+ - Linux (uses `SIOCGIFADDR` for IP detection, UDP multicast for discovery) - [`cbor2`](https://pypi.org/project/cbor2/) +- [`ipython`](https://pypi.org/project/ipython/) ≥ 9 (shell only) +- [`rich`](https://pypi.org/project/rich/) ≥ 15 (shell only) diff --git a/xbot_service_interface/examples/gpio.json b/xbot_service_interface/examples/gpio.json new file mode 100644 index 0000000..b9b8d97 --- /dev/null +++ b/xbot_service_interface/examples/gpio.json @@ -0,0 +1,24 @@ +{ + "gpios": [ + { + "direction": "input", + "id": 7, + "line": "GPIO7", + "name": "Input GPIO7" + }, + { + "default": 0, + "direction": "output", + "id": 6, + "line": "GPIO6", + "name": "Output GPIO6" + } + ], + "i2c": [ + { + "bus": "I2C2", + "id": 0, + "name": "I2C2" + } + ] +} diff --git a/xbot_service_interface/examples/lcd_hello.py b/xbot_service_interface/examples/lcd_hello.py new file mode 100644 index 0000000..1632c39 --- /dev/null +++ b/xbot_service_interface/examples/lcd_hello.py @@ -0,0 +1,159 @@ +""" +Write "hello world" to a 1602 LCD connected via PCF8574 I2C expander. + +Uses RemoteGPIOService (id=10). Configures with gpio.json (heatshrink-compressed) +and 1000 ms periodic update interval. + +Typical PCF8574 addresses: 0x27 (A0-A2 high) or 0x3F (A0-A2 low). +""" +import sys +import time +import logging +from pathlib import Path + +import heatshrink2 + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from xbot_service_interface import XbotServiceIo, ServiceInterface + +logging.basicConfig(level=logging.WARNING) + +# ── Config ──────────────────────────────────────────────────────────────────── + +BIND_IP = '172.16.78.151' +GPIO_JSON = Path(__file__).parent / 'gpio.json' +LCD_ADDR = 0x27 # PCF8574 I2C address — change to 0x3F if needed +I2C_BUS = 0 # matches "id": 0 in gpio.json + +# ── HD44780 via PCF8574 ─────────────────────────────────────────────────────── +# PCF8574 pin mapping: +# P7-P4 → D7-D4 (high nibble) +# P3 → Backlight +# P2 → Enable +# P1 → R/W (always 0 = write) +# P0 → RS (0=command, 1=data) + +_BL = 0x08 +_EN = 0x04 +_RS = 0x01 + + +def _nibble_bytes(nibble: int, flags: int) -> bytes: + """Pulse Enable for one 4-bit nibble. Returns 2 I2C bytes.""" + b = (nibble & 0xF0) | flags | _BL + return bytes([b | _EN, b & ~_EN]) + + +def _byte_bytes(value: int, rs: int) -> bytes: + """Encode a full byte as two nibble pulses (4 I2C bytes).""" + return (_nibble_bytes(value & 0xF0, rs) + + _nibble_bytes((value << 4) & 0xF0, rs)) + + +def _tx(svc, data: bytes): + svc.call_i2_ctransmit(I2C_BUS, LCD_ADDR, data, timeout_ms=1500) + + +def lcd_cmd(svc, cmd: int): + _tx(svc, _byte_bytes(cmd, 0)) + + +def lcd_char(svc, ch: str): + _tx(svc, _byte_bytes(ord(ch), _RS)) + + +def lcd_init(svc): + lcd_cmd(svc, 0x33) + lcd_cmd(svc, 0x32) + lcd_cmd(svc, 0x06) + lcd_cmd(svc, 0x0C) + lcd_cmd(svc, 0x28) + lcd_cmd(svc, 0x01) + time.sleep(0.0005) + + +def lcd_write(svc, text: str, line: int = 0): + addr = 0x80 if line == 0 else 0xC0 + lcd_cmd(svc, addr) + for ch in text[:16]: + lcd_char(svc, ch) + + +def create_char(svc, location, charmap): + """Write custom char to CGRAM""" + location &= 0x7 # Only 8 slots (0–7) + lcd_cmd(svc, 0x40 | (location << 3)) + for byte in charmap: + _tx(svc, _byte_bytes(byte, _RS)) + + +def define_custom_characters(svc): + # emergency + create_char(svc, 0, [0x0E, 0x0E, 0x0E, 0x0E, 0x0E, 0x00, 0x0E, 0x0E]) + # battery empty + create_char(svc, 1, [0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1F, 0x00]) + # battery 50% + create_char(svc, 2, [0x0E, 0x11, 0x11, 0x11, 0x1F, 0x1F, 0x1F, 0x00]) + # battery full + create_char(svc, 3, [0x0E, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x1F, 0x00]) + # battery charging + create_char(svc, 4, [0x0E, 0x1B, 0x17, 0x11, 0x1D, 0x1B, 0x1F, 0x00]) + # gps no rtk + create_char(svc, 5, [0x00, 0x0E, 0x19, 0x15, 0x13, 0x0E, 0x00, 0x00]) + # gps rtk float + create_char(svc, 6, [0x00, 0x0E, 0x11, 0x11, 0x11, 0x0E, 0x00, 0x00]) + # gps rtk fixed + create_char(svc, 7, [0x00, 0x0E, 0x1F, 0x1B, 0x1F, 0x0E, 0x00, 0x00]) + + +# ── Main ────────────────────────────────────────────────────────────────────── + +def main(): + xbot = XbotServiceIo(bind_ip=BIND_IP) + svc = ServiceInterface(service_id=10) + xbot.register(svc) + + gpio_blob = heatshrink2.compress( + GPIO_JSON.read_bytes(), window_sz2=9, lookahead_sz2=5) + + @svc.on_connected + def connected(): + print("RemoteGPIOService connected — initialising LCD…") + svc.registers['gpio_configs'] = gpio_blob + svc.registers['periodic_update_interval'] = 1000 + try: + lcd_init(svc) + define_custom_characters(svc) + print("Done.") + except Exception as e: + print(f"LCD error: {e}") + + @svc.on_disconnected + def disconnected(): + print("RemoteGPIOService disconnected.") + + + + xbot.start() + print("Waiting for RemoteGPIOService (id=10)…") + + try: + counter = 0 + while xbot.ok(): + if svc.connected: + try: + lcd_write(svc, f"Counter: {counter}", line=0) + except Exception as e: + print(f"Update error: {e}") + counter += 1 + + # time.sleep(0.01) + except KeyboardInterrupt: + pass + finally: + xbot.stop() + + +if __name__ == '__main__': + main() diff --git a/xbot_service_interface/pyproject.toml b/xbot_service_interface/pyproject.toml index 13ec407..bacf091 100644 --- a/xbot_service_interface/pyproject.toml +++ b/xbot_service_interface/pyproject.toml @@ -39,5 +39,9 @@ include = ["xbot_service_interface*"] [tool.pytest.ini_options] testpaths = ["tests"] +[project.scripts] +xbot-shell = "xbot_service_interface.shell:main" + [project.optional-dependencies] dev = ["pytest>=8"] +shell = ["ipython>=8", "rich>=13", "heatshrink2>=0.5"] diff --git a/xbot_service_interface/requirements.txt b/xbot_service_interface/requirements.txt index b2fe541..2dc12e7 100644 --- a/xbot_service_interface/requirements.txt +++ b/xbot_service_interface/requirements.txt @@ -1 +1,4 @@ cbor2>=6.1.1 +ipython>=9.13.0 +rich>=15.0.0 +heatshrink2>=0.5 diff --git a/xbot_service_interface/xbot_service_interface/interface.py b/xbot_service_interface/xbot_service_interface/interface.py index 7652f9a..2a42da9 100644 --- a/xbot_service_interface/xbot_service_interface/interface.py +++ b/xbot_service_interface/xbot_service_interface/interface.py @@ -194,6 +194,7 @@ def __init__(self, service_id: int, object.__setattr__(self, '_lock', threading.Lock()) # RPC synchronization state + object.__setattr__(self, '_rpc_lock', threading.Lock()) object.__setattr__(self, '_rpc_condition', threading.Condition()) object.__setattr__(self, '_rpc_call_active', False) object.__setattr__(self, '_rpc_call_counter', 0) @@ -209,6 +210,11 @@ def __init__(self, service_id: int, # Lifecycle callbacks # ------------------------------------------------------------------ + @property + def connected(self) -> bool: + """True if the service is currently claimed and connected.""" + return self._connected + def on_connected(self, callback: Callable) -> Callable: """Register connected callback. Use as decorator or direct call.""" self._connected_callbacks.append(callback) @@ -308,37 +314,34 @@ def _call_rpc(self, fn: dict, args: tuple, timeout_ms: int): raw = pack_value(param['type_str'], arg, enums) params_bytes += pack_descriptor(param['id'], len(raw)) + raw - with self._rpc_condition: - # Raises immediately on concurrent calls (C++ binding blocks instead). - if self._rpc_call_active: - raise RuntimeError( - f"RPC call already in progress for service {self._service_id}") - counter = (self._rpc_call_counter + 1) & 0xFFFF - object.__setattr__(self, '_rpc_call_counter', counter) - object.__setattr__(self, '_pending_call_id', counter) - object.__setattr__(self, '_rpc_call_active', True) - - if not self._io or not self._io.send_rpc_call( - self._service_id, fn['id'], counter, params_bytes): - object.__setattr__(self, '_rpc_call_active', False) - raise RuntimeError( - f"Failed to send RPC call {fn['name']!r} for service {self._service_id}") - - ok = self._rpc_condition.wait_for( - lambda: not self._rpc_call_active, - timeout=timeout_ms / 1000.0, - ) - if not ok: - object.__setattr__(self, '_rpc_call_active', False) - raise RpcTimeoutError( - f"RPC call {fn['name']!r} timed out after {timeout_ms} ms") - - if not self._connected: - raise RuntimeError( - f"Service {self._service_id} disconnected during RPC call {fn['name']!r}") - - status = self._rpc_response_status - payload = self._rpc_response_payload + with self._rpc_lock: + with self._rpc_condition: + counter = (self._rpc_call_counter + 1) & 0xFFFF + object.__setattr__(self, '_rpc_call_counter', counter) + object.__setattr__(self, '_pending_call_id', counter) + object.__setattr__(self, '_rpc_call_active', True) + + if not self._io or not self._io.send_rpc_call( + self._service_id, fn['id'], counter, params_bytes): + object.__setattr__(self, '_rpc_call_active', False) + raise RuntimeError( + f"Failed to send RPC call {fn['name']!r} for service {self._service_id}") + + ok = self._rpc_condition.wait_for( + lambda: not self._rpc_call_active, + timeout=timeout_ms / 1000.0, + ) + if not ok: + object.__setattr__(self, '_rpc_call_active', False) + raise RpcTimeoutError( + f"RPC call {fn['name']!r} timed out after {timeout_ms} ms") + + if not self._connected: + raise RuntimeError( + f"Service {self._service_id} disconnected during RPC call {fn['name']!r}") + + status = self._rpc_response_status + payload = self._rpc_response_payload if status == 1: raise RpcBusyError() @@ -393,11 +396,17 @@ def _on_service_discovered(self, ip: str, port: int, def _on_claim_ack(self) -> None: object.__setattr__(self, '_connected', True) log.info(f"ServiceInterface {self._service_id} connected") - for cb in self._connected_callbacks: - try: - cb() - except Exception: - log.exception("Error in on_connected callback") + # Fire callbacks in a separate thread so callers can make RPC calls + # without deadlocking the IO receive thread. + callbacks = list(self._connected_callbacks) + def _fire(): + for cb in callbacks: + try: + cb() + except Exception: + log.exception("Error in on_connected callback") + threading.Thread(target=_fire, daemon=True, + name=f'xbot-connected-{self._service_id}').start() def _on_data(self, timestamp: int, target_id: int, payload: bytes) -> None: schema = self._active_schema @@ -429,16 +438,21 @@ def _on_config_request(self) -> None: log.warning(f"Service {self._service_id} requested config but schema unavailable") return + # Build a snake_case-normalised view of stored keys so callers + # can use any capitalisation (e.g. 'GpioConfigs', 'gpio_configs', + # 'GPIO Configs') and still match the schema entry. + from .serialization import to_snake_case as _snake + snake_lookup = {_snake(k): v for k, v in self._register_values.items()} + chunks = [] + missing_required = [] for reg in schema.registers: - # Accept original name or snake_name as key in _register_values value = self._register_values.get(reg['name'], - self._register_values.get(reg['snake_name'])) + self._register_values.get(reg['snake_name'], + snake_lookup.get(reg['snake_name']))) if value is None: if not reg.get('optional', False): - log.warning( - f"Required register {reg['name']!r} not set " - f"for service {self._service_id}") + missing_required.append(reg['name']) continue try: raw = pack_value(reg['type_str'], value, schema.enums_dict) @@ -446,6 +460,11 @@ def _on_config_request(self) -> None: except Exception as e: log.error(f"Cannot serialize register {reg['name']!r}: {e}") + if missing_required: + names = ', '.join(missing_required) + log.debug( + f"Service {self._service_id}: required registers not set: {names}") + if chunks and self._io is not None: self._io.send_transaction(self._service_id, chunks, is_config=True) log.info( @@ -472,8 +491,12 @@ def _on_disconnected(self) -> None: object.__setattr__(self, '_rpc_call_active', False) self._rpc_condition.notify_all() log.info(f"ServiceInterface {self._service_id} disconnected") - for cb in self._disconnected_callbacks: - try: - cb() - except Exception: - log.exception("Error in on_disconnected callback") + callbacks = list(self._disconnected_callbacks) + def _fire(): + for cb in callbacks: + try: + cb() + except Exception: + log.exception("Error in on_disconnected callback") + threading.Thread(target=_fire, daemon=True, + name=f'xbot-disconnected-{self._service_id}').start() diff --git a/xbot_service_interface/xbot_service_interface/manager.py b/xbot_service_interface/xbot_service_interface/manager.py index 38dfb21..026fcca 100644 --- a/xbot_service_interface/xbot_service_interface/manager.py +++ b/xbot_service_interface/xbot_service_interface/manager.py @@ -41,13 +41,18 @@ def __init__(self, bind_ip: str = '0.0.0.0'): self._listener = _DiscoveryListener(self) def register(self, interface: ServiceInterface) -> None: - """Register a ServiceInterface before calling start().""" + """Register a ServiceInterface before or after calling start().""" sid = interface._service_id if sid in self._interfaces: log.warning(f"Overwriting existing ServiceInterface for service_id={sid}") self._interfaces[sid] = interface interface._io = self._io + # If already discovered, trigger connection flow immediately + info = self._discovery.get_service_info(sid) + if info: + self._on_service_found(sid, info['ip'], info['port'], info['schema']) + def start(self) -> None: """Start IO and discovery threads.""" self._io.start() diff --git a/xbot_service_interface/xbot_service_interface/shell.py b/xbot_service_interface/xbot_service_interface/shell.py new file mode 100644 index 0000000..5f636a0 --- /dev/null +++ b/xbot_service_interface/xbot_service_interface/shell.py @@ -0,0 +1,890 @@ +""" +Interactive IPython shell for xBot services. + +Usage: + xbot-shell [--bind ] + +In the shell: + services() — list discovered services + connect(id_or_name) — connect to a service, returns ServiceProxy + svc.info() — show service schema + svc.wait_connected()— block until claimed + svc.watch_all() — print all output updates to console + svc.send_ — tab-complete inputs + svc.call_ — tab-complete RPC functions + svc.registers. — tab-complete registers +""" +import ast +import sys +import threading +import time +from typing import Any, Optional + +try: + from rich.console import Console + from rich.table import Table + HAS_RICH = True +except ImportError: + HAS_RICH = False + +try: + import IPython + from IPython.terminal.embed import InteractiveShellEmbed + from IPython.core.magic import register_line_magic + HAS_IPYTHON = True +except ImportError: + HAS_IPYTHON = False + +from .interface import ServiceInterface +from .manager import XbotServiceIo +from .schema import ServiceSchema + + +_console: Optional['Console'] = Console() if HAS_RICH else None # type: ignore[assignment] + + +# --------------------------------------------------------------------------- +# CompletableRegisters +# --------------------------------------------------------------------------- + +class CompletableRegisters: + """Dict/attribute access to service registers with tab completion support.""" + + def __init__(self, iface: ServiceInterface): + object.__setattr__(self, '_iface', iface) + + def __dir__(self): + iface = object.__getattribute__(self, '_iface') + schema = iface._active_schema + if schema is None: + return [] + return [r['snake_name'] for r in schema.registers] + + def __getattr__(self, name: str): + iface = object.__getattribute__(self, '_iface') + schema = iface._active_schema + if schema is not None: + for r in schema.registers: + if r['snake_name'] == name or r['name'] == name: + val = iface._register_values.get(r['name'], + iface._register_values.get(r['snake_name'])) + if val is None: + raise AttributeError(f"Register {name!r} not set yet") + return val + raise AttributeError(name) + + def __setattr__(self, name: str, value: Any): + iface = object.__getattribute__(self, '_iface') + iface.registers[name] = value + + def __getitem__(self, name: str): + iface = object.__getattribute__(self, '_iface') + return iface.registers[name] + + def __setitem__(self, name: str, value: Any): + iface = object.__getattribute__(self, '_iface') + iface.registers[name] = value + + def __repr__(self) -> str: + iface = object.__getattribute__(self, '_iface') + schema = iface._active_schema + if schema is None: + return '' + lines = [] + for r in schema.registers: + val = iface._register_values.get(r['name'], + iface._register_values.get(r['snake_name'], '')) + flag = ' (optional)' if r.get('optional') else '' + lines.append(f" {r['snake_name']}: {r['type_str']} = {val!r}{flag}") + return ('Registers:\n' + '\n'.join(lines)) if lines else 'Registers: ' + + +# --------------------------------------------------------------------------- +# ServiceProxy +# --------------------------------------------------------------------------- + +class ServiceProxy: + """Tab-completable proxy for a connected xBot service. + + Dynamic attributes (populated once the service schema is known): + send_{input_snake_name}(value) — send to a service input + call_{function_snake_name}(*args) — synchronous RPC call + on_{output_snake_name}_changed = cb — subscribe to output callback + + Register access: + svc.registers.prefix = "hello: " (attribute style) + svc.registers['Prefix'] = "hello: " (dict style) + + Helpers: + svc.info() — print schema details + svc.wait_connected() — block until the service is claimed + svc.watch('name') — print specific output to console + svc.watch_all() — print all outputs to console + """ + + def __init__(self, iface: ServiceInterface): + object.__setattr__(self, '_iface', iface) + object.__setattr__(self, 'registers', CompletableRegisters(iface)) + + # ------------------------------------------------------------------ + # Tab completion — IPython calls dir() on the proxy object + # ------------------------------------------------------------------ + + def __dir__(self): + base = ['registers', 'connected', 'transaction', 'on_connected', 'on_disconnected', + 'info', 'watch', 'watch_all', 'wait_connected', 'configure_registers'] + iface = object.__getattribute__(self, '_iface') + schema = iface._active_schema + if schema is not None: + base += [f'send_{ch["snake_name"]}' for ch in schema.inputs] + base += [f'call_{fn["snake_name"]}' for fn in schema.functions] + base += [f'on_{ch["snake_name"]}_changed' for ch in schema.outputs] + return sorted(set(base)) + + # ------------------------------------------------------------------ + # Attribute dispatch + # ------------------------------------------------------------------ + + def __getattr__(self, name: str): + iface = object.__getattribute__(self, '_iface') + fn = getattr(iface, name) + + # Attach docstrings to dynamic methods so IPython can show signatures + schema = iface._active_schema + if schema is not None: + try: + if name.startswith('call_'): + rpc = schema.get_function(name[len('call_'):]) + params = ', '.join( + f'{p["snake_name"]}: {p["type_str"]}' + for p in rpc['parameters'] + ) + fn.__doc__ = ( + f"{rpc['name']}({params}) -> {rpc['return_type']}\n\n" + f"RPC call. Optional keyword: timeout_ms (default 1000)." + ) + elif name.startswith('send_'): + ch = schema.get_input(name[len('send_'):]) + fn.__doc__ = f"Send {ch['name']} ({ch['type_str']}) to service." + except Exception: + pass + + return fn + + def __setattr__(self, name: str, value: Any): + if name.startswith('on_') and name.endswith('_changed'): + iface = object.__getattribute__(self, '_iface') + setattr(iface, name, value) + else: + object.__setattr__(self, name, value) + + # ------------------------------------------------------------------ + # Helper methods + # ------------------------------------------------------------------ + + def wait_connected(self, timeout: float = 10.0) -> bool: + """Block until the service is claimed, or timeout seconds pass.""" + iface = object.__getattribute__(self, '_iface') + if iface._connected: + _print(f"[green]✓[/green] Already connected", plain="✓ Already connected") + self._warn_unconfigured(iface) + return True + ev = threading.Event() + iface.on_connected(lambda: ev.set()) + ok = ev.wait(timeout=timeout) + if ok: + schema = iface._active_schema + name = schema.type if schema else f'service {iface._service_id}' + _print(f"[green]✓[/green] Connected to [bold]{name}[/bold]", + plain=f"✓ Connected to {name}") + self._warn_unconfigured(iface) + else: + _print(f"[yellow]⚠[/yellow] Timeout after {timeout}s — service not connected", + plain=f"⚠ Timeout after {timeout}s") + return ok + + @staticmethod + def _warn_unconfigured(iface): + missing = _missing_required(iface) + if missing: + names = ', '.join(missing) + _print( + f" [yellow]⚠[/yellow] Required registers not set: " + f"[bold]{names}[/bold] — call [bold]configure_registers()[/bold]", + plain=f" ⚠ Required registers not set: {names}" + " — call configure_registers()", + ) + + def configure_registers(self): + """Interactive wizard: prompt for each register value, then send config. + + Press Enter to keep the current value. Type '-' to clear an optional register. + """ + iface = object.__getattribute__(self, '_iface') + schema = iface._active_schema + if schema is None: + _print("[yellow]No schema — call wait_connected() first[/yellow]", + plain="No schema — call wait_connected() first") + return + if not schema.registers: + _print("[dim]Service has no registers.[/dim]", plain="Service has no registers.") + return + + _print(f"\n[bold]Configuring registers for {schema.type}[/bold] " + f"[dim](Enter = keep current, '-' = clear optional)[/dim]\n", + plain=f"\nConfiguring registers for {schema.type}" + " (Enter = keep current, '-' = clear optional)\n") + + changed = False + for reg in schema.registers: + current = iface._register_values.get( + reg['name'], iface._register_values.get(reg['snake_name'])) + optional = reg.get('optional', False) + + flag = ' [dim](optional)[/dim]' if optional else ' [red]*[/red]' + hint = _type_hint(reg['type_str'], schema.enums_dict) + + from .serialization import parse_type_string as _pts + is_blob = _pts(reg['type_str'])[0] == 'blob' + value_label = 'file path' if is_blob else 'new value' + + if HAS_RICH and _console is not None: + _console.print( + f" [cyan]{reg['snake_name']}[/cyan]{flag} " + f"[dim]{reg['type_str']}[/dim]" + + (f" [dim]hint: {hint}[/dim]" if hint else "") + ) + if is_blob and current is not None: + current_s = f'[dim]{len(current)} bytes[/dim]' + elif current is not None: + current_s = repr(current) + else: + current_s = '[dim][/dim]' + prompt_str = f" current={current_s} {value_label}: " + raw = _console.input(prompt_str) + if is_blob and raw.strip(): + compress_s = _console.input( + " heatshrink compress? [y/N]: ") + if compress_s.strip().lower() == 'y': + raw = raw.strip() + ' --compress' + else: + if is_blob and current is not None: + current_s = f'{len(current)} bytes' + elif current is not None: + current_s = repr(current) + else: + current_s = '' + type_s = f" [{reg['type_str']}]" + (f" ({hint})" if hint else "") + raw = input(f" {reg['snake_name']}{type_s} current={current_s} {value_label}: ") + if is_blob and raw.strip(): + compress_s = input(" heatshrink compress? [y/N]: ") + if compress_s.strip().lower() == 'y': + raw = raw.strip() + ' --compress' + + raw = raw.strip() + + if raw == '': + continue # keep current + + if raw == '-': + if optional: + iface._register_values.pop(reg['name'], None) + iface._register_values.pop(reg['snake_name'], None) + _print(f" [dim]cleared[/dim]", plain=" cleared") + changed = True + else: + _print(f" [yellow]Cannot clear required register — skipped[/yellow]", + plain=" Cannot clear required register — skipped") + continue + + try: + value = _parse_register_input(reg['type_str'], raw, schema.enums_dict) + iface._register_values[reg['name']] = value + _print(f" [green]✓[/green] set to {value!r}", plain=f" ✓ set to {value!r}") + changed = True + except (ValueError, TypeError) as e: + _print(f" [red]✗ parse error: {e} — skipped[/red]", + plain=f" ✗ parse error: {e} — skipped") + + if changed and iface._connected and schema is not None and iface._io is not None: + iface._on_config_request() + _print("\n[green]✓[/green] Configuration sent.\n", plain="\n✓ Configuration sent.\n") + elif changed: + _print("\n[dim]Values stored — will be sent on next connect.[/dim]\n", + plain="\nValues stored — will be sent on next connect.\n") + else: + _print("\n[dim]No changes.[/dim]\n", plain="\nNo changes.\n") + + def watch(self, *output_names: str): + """Print received output values to the console. + + Args: + *output_names: snake_case output names. If none given, watches all. + """ + iface = object.__getattribute__(self, '_iface') + schema = iface._active_schema + if schema is None: + print("Not connected — call wait_connected() first") + return + targets = schema.outputs if not output_names else [ + schema.get_output(n) for n in output_names + ] + for ch in targets: + _install_watcher(iface, ch) + names = ', '.join(ch['snake_name'] for ch in targets) + _print(f"[dim]Watching:[/dim] {names}", plain=f"Watching: {names}") + + def watch_all(self): + """Print all output values to the console as they arrive.""" + self.watch() + + def info(self): + """Print a rich summary of this service's schema.""" + iface = object.__getattribute__(self, '_iface') + _print_schema(iface) + + def transaction(self): + """Context manager: buffer multiple sends into one UDP transaction.""" + iface = object.__getattribute__(self, '_iface') + return iface.transaction() + + # ------------------------------------------------------------------ + # Display + # ------------------------------------------------------------------ + + def __repr__(self) -> str: + iface = object.__getattribute__(self, '_iface') + schema = iface._active_schema + sid = iface._service_id + if schema is None: + return f'' + status = 'connected' if iface._connected else 'discovered' + inputs_s = ', '.join(ch['snake_name'] for ch in schema.inputs) or '—' + outputs_s = ', '.join(ch['snake_name'] for ch in schema.outputs) or '—' + fns_s = ', '.join(fn['snake_name'] for fn in schema.functions) or '—' + return ( + f"\n" + f" inputs: {inputs_s}\n" + f" outputs: {outputs_s}\n" + f" functions: {fns_s}" + ) + + +# --------------------------------------------------------------------------- +# Rich helpers +# --------------------------------------------------------------------------- + +def _missing_required(iface) -> list: + """Return list of required register names that have no value set.""" + schema = iface._active_schema + if schema is None: + return [] + missing = [] + for r in schema.registers: + if not r.get('optional', False): + if (r['name'] not in iface._register_values and + r['snake_name'] not in iface._register_values): + missing.append(r['snake_name']) + return missing + + +def _config_status(iface) -> tuple[str, str]: + """Return (rich_string, plain_string) config status for a service.""" + schema = iface._active_schema + if schema is None or not schema.registers: + return '', '' + missing = _missing_required(iface) + if missing: + return '[yellow]⚠ needs config[/yellow]', '⚠ needs config' + return '[green]configured[/green]', 'configured' + + +def _heatshrink_compress(data: bytes) -> bytes: + """Compress bytes with heatshrink (window=9, lookahead=5 — matches C++ defaults).""" + try: + import heatshrink2 + return heatshrink2.compress(data, window_sz2=9, lookahead_sz2=5) + except ImportError: + raise RuntimeError( + "heatshrink2 not installed. Run: pip install heatshrink2") + + +def _parse_blob_input(raw: str) -> tuple[str, bool]: + """Parse blob input string: 'path/to/file [--compress]' → (path, compress).""" + compress = '--compress' in raw + path = raw.replace('--compress', '').strip().strip('"\'') + return path, compress + + +def _type_hint(type_str: str, enums: dict) -> str: + """Return a short human hint for a type, e.g. for arrays or enums.""" + from .serialization import parse_type_string + base, is_array, max_len = parse_type_string(type_str) + if base == 'blob': + return 'path to file (will ask about heatshrink compression)' + if base in enums: + vals = ', '.join(enums[base]['values'].keys()) + return f"one of: {vals}" + if is_array and base != 'char': + return f"comma-separated list of {max_len} {base} values" + return '' + + +def _parse_register_input(type_str: str, raw: str, enums: dict) -> Any: + """Convert user-typed string to a Python value matching type_str.""" + from .serialization import parse_type_string + base, is_array, max_len = parse_type_string(type_str) + + # Enum + if base in enums: + enum_def = enums[base] + if raw in enum_def['values']: + return raw # pack_value accepts name string + try: + return int(raw) + except ValueError: + raise ValueError( + f"Expected one of {list(enum_def['values'].keys())} or an integer, got {raw!r}") + + # String / char array + if base == 'char': + # strip surrounding quotes if user typed them + if len(raw) >= 2 and raw[0] in ('"', "'") and raw[-1] == raw[0]: + raw = raw[1:-1] + return raw + + # Numeric array + if is_array: + # Accept both "1, -2, 3" and "[1, -2, 3]" + try: + parsed = ast.literal_eval(raw if raw.startswith('[') else f'[{raw}]') + except (ValueError, SyntaxError) as e: + raise ValueError(f"Cannot parse list: {e}") + if not isinstance(parsed, list): + raise ValueError("Expected a list of values") + conv = float if base in ('float', 'double') else int + return [conv(v) for v in parsed] + + # Blob — treat input as file path (with optional heatshrink compression) + if base == 'blob': + path, compress = _parse_blob_input(raw) + try: + with open(path, 'rb') as f: + data = f.read() + except OSError as e: + raise ValueError(f"Cannot read file {path!r}: {e}") + if compress: + data = _heatshrink_compress(data) + return data + + # Scalar numeric + if base in ('float', 'double'): + return float(raw) + return int(raw, 0) # 0 base supports 0x hex, 0b binary, etc. + + +def _print(rich_msg: str, plain: Optional[str] = None): + if HAS_RICH and _console is not None: + _console.print(rich_msg) + else: + print(plain or rich_msg) + + +def _install_watcher(iface: ServiceInterface, ch: dict): + snake = ch['snake_name'] + + def _cb(value, timestamp): + ts_ms = timestamp // 1000 + if HAS_RICH and _console is not None: + _console.print( + f"[dim]{ts_ms:>12}ms[/dim] " + f"[cyan]{snake}[/cyan] = {value!r}" + ) + else: + print(f"{ts_ms:>12}ms {snake} = {value!r}") + + iface._output_callbacks[snake] = _cb + + +def _print_schema(iface): + sid = iface._service_id + schema = iface._active_schema + connected = iface._connected + + if schema is None: + _print(f"[yellow]Service {sid}: schema not available[/yellow]", + plain=f"Service {sid}: schema not available") + return + + status_s = '[green]connected[/green]' if connected else '[yellow]discovered[/yellow]' + plain_status = 'connected' if connected else 'discovered' + + cfg_rich, cfg_plain = _config_status(iface) + + if HAS_RICH and _console is not None: + header = ( + f"[bold]{schema.type}[/bold] v{schema.version} " + f"(id={sid}) {status_s}" + ) + if cfg_rich: + header += f" {cfg_rich}" + _console.print() + _console.print(header) + + if schema.inputs: + t = Table(title='Inputs', show_header=True, header_style='bold blue', + show_lines=False) + t.add_column('method') + t.add_column('type', style='dim') + for ch in schema.inputs: + t.add_row(f'send_{ch["snake_name"]}(value)', ch['type_str']) + _console.print(t) + + if schema.outputs: + t = Table(title='Outputs', show_header=True, header_style='bold green', + show_lines=False) + t.add_column('callback attribute') + t.add_column('type', style='dim') + for ch in schema.outputs: + t.add_row(f'on_{ch["snake_name"]}_changed', ch['type_str']) + _console.print(t) + + if schema.registers: + t = Table(title='Registers', show_header=True, header_style='bold magenta', + show_lines=False) + t.add_column('registers.name') + t.add_column('type', style='dim') + t.add_column('value') + t.add_column('req', justify='center', style='dim') + for r in schema.registers: + val = iface._register_values.get( + r['name'], iface._register_values.get(r['snake_name'])) + if val is None: + if r.get('optional'): + val_s = '[dim]—[/dim]' + else: + val_s = '[yellow][/yellow]' + elif r['type_str'] == 'blob' or ( + isinstance(val, (bytes, bytearray)) ): + val_s = f'[dim]{len(val)} bytes[/dim]' + else: + val_s = repr(val) + req_s = '' if r.get('optional') else '[red]*[/red]' + t.add_row(r['snake_name'], r['type_str'], val_s, req_s) + _console.print(t) + + if schema.functions: + t = Table(title='RPC Functions', show_header=True, + header_style='bold yellow', show_lines=False) + t.add_column('method') + t.add_column('parameters', style='dim') + t.add_column('returns', style='dim') + for fn in schema.functions: + params_s = ', '.join( + f'{p["snake_name"]}: {p["type_str"]}' for p in fn['parameters'] + ) or '—' + t.add_row(f'call_{fn["snake_name"]}(...)', params_s, fn['return_type']) + _console.print(t) + + _console.print() + else: + cfg_part = f' [{cfg_plain}]' if cfg_plain else '' + print(f"\n{schema.type} v{schema.version} (id={sid}) [{plain_status}]{cfg_part}") + if schema.inputs: + print(" Inputs:") + for ch in schema.inputs: + print(f" send_{ch['snake_name']}(value) [{ch['type_str']}]") + if schema.outputs: + print(" Outputs:") + for ch in schema.outputs: + print(f" on_{ch['snake_name']}_changed [{ch['type_str']}]") + if schema.registers: + print(" Registers:") + for r in schema.registers: + val = iface._register_values.get( + r['name'], iface._register_values.get(r['snake_name'])) + val_s = f'{len(val)} bytes' if isinstance(val, (bytes, bytearray)) \ + else (repr(val) if val is not None else '') + flag = ' (optional)' if r.get('optional') else ' *' + print(f" registers.{r['snake_name']} [{r['type_str']}]{flag} = {val_s}") + if schema.functions: + print(" RPC Functions:") + for fn in schema.functions: + params_s = ', '.join( + f'{p["snake_name"]}: {p["type_str"]}' for p in fn['parameters'] + ) + print(f" call_{fn['snake_name']}({params_s}) -> {fn['return_type']}") + print() + + +# --------------------------------------------------------------------------- +# Discovery tracker +# --------------------------------------------------------------------------- + +class _DiscoveryTracker: + """Collects all service advertisements independent of registered interfaces.""" + + def __init__(self): + self._lock = threading.Lock() + self._services: dict[int, dict] = {} + + def on_service_found(self, sid: int, ip: str, port: int, + schema: ServiceSchema): + with self._lock: + is_new = sid not in self._services + self._services[sid] = {'ip': ip, 'port': port, 'schema': schema} + + if is_new: + _print( + f"\n[bold green]→[/bold green] Discovered: " + f"[bold]{schema.type}[/bold] (id={sid}) at {ip}:{port} " + f"— connect({sid})", + plain=f"\n→ Discovered: {schema.type} (id={sid}) at {ip}:{port}" + f" — connect({sid})", + ) + + def snapshot(self) -> dict: + with self._lock: + return dict(self._services) + + +# --------------------------------------------------------------------------- +# XbotShell +# --------------------------------------------------------------------------- + +class XbotShell: + """Discovery + connection manager for the interactive shell.""" + + def __init__(self, bind_ip: str = '0.0.0.0'): + self._io = XbotServiceIo(bind_ip=bind_ip) + self._tracker = _DiscoveryTracker() + self._proxies: dict[int, ServiceProxy] = {} + + def start(self): + self._io._discovery.register_listener(self._tracker) + self._io.start() + + def stop(self): + self._io.stop() + + # ------------------------------------------------------------------ + # Shell commands + # ------------------------------------------------------------------ + + def services(self): + """List all discovered services.""" + known = self._tracker.snapshot() + if not known: + _print('[yellow]No services discovered yet. Are services running?[/yellow]', + plain='No services discovered yet.') + return + + if HAS_RICH and _console is not None: + t = Table(title='Discovered Services', show_header=True, show_lines=False) + t.add_column('ID', justify='right', style='bold') + t.add_column('Type') + t.add_column('Endpoint', style='dim') + t.add_column('In', justify='right', style='blue') + t.add_column('Out', justify='right', style='green') + t.add_column('Fn', justify='right', style='yellow') + t.add_column('Status') + t.add_column('Config') + for sid, info in sorted(known.items()): + s: ServiceSchema = info['schema'] + proxy = self._proxies.get(sid) + connected = proxy is not None and proxy._iface._connected + status_s = '[green]connected[/green]' if connected else '' + if proxy is not None: + cfg_rich, _ = _config_status(proxy._iface) + else: + cfg_rich = '' + t.add_row( + str(sid), s.type, + f"{info['ip']}:{info['port']}", + str(len(s.inputs)), + str(len(s.outputs)), + str(len(s.functions)), + status_s, + cfg_rich, + ) + _console.print(t) + else: + for sid, info in sorted(known.items()): + s: ServiceSchema = info['schema'] + proxy = self._proxies.get(sid) + connected = proxy is not None and proxy._iface._connected + _, cfg_plain = _config_status(proxy._iface) if proxy else ('', '') + flags = (' [connected]' if connected else '') + (f' [{cfg_plain}]' if cfg_plain else '') + print(f" [{sid}] {s.type} @ {info['ip']}:{info['port']}{flags}") + + def connect(self, id_or_name) -> ServiceProxy: + """Connect to a service by ID (int) or type name (str). + + Returns a ServiceProxy. Call svc.wait_connected() to block until ready. + """ + known = self._tracker.snapshot() + + target_sid: Optional[int] = None + if isinstance(id_or_name, int): + if id_or_name in known: + target_sid = id_or_name + else: + needle = str(id_or_name).lower() + for sid, info in known.items(): + if info['schema'].type.lower() == needle: + target_sid = sid + break + + if target_sid is None: + avail = ', '.join( + f"{s['schema'].type}({sid})" for sid, s in sorted(known.items()) + ) or 'none' + raise ValueError( + f"Service {id_or_name!r} not found. " + f"Available: {avail}. " + f"Call services() to list." + ) + + if target_sid in self._proxies: + existing = self._proxies[target_sid] + _print(f"[yellow]Reusing existing proxy for service {target_sid}[/yellow]", + plain=f"Reusing existing proxy for service {target_sid}") + return existing + + info = known[target_sid] + schema: ServiceSchema = info['schema'] + + _print( + f"Connecting to [bold]{schema.type}[/bold] (id={target_sid})…" + f" call [bold]wait_connected()[/bold] on result to block until ready.", + plain=f"Connecting to {schema.type} (id={target_sid})...", + ) + + iface = ServiceInterface(service_id=target_sid) + self._io.register(iface) + proxy = ServiceProxy(iface) + self._proxies[target_sid] = proxy + return proxy + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def main(argv=None): + if not HAS_IPYTHON: + print("IPython not installed. Install the shell extras:") + print(" pip install xbot-service-interface[shell]") + sys.exit(1) + + import argparse + parser = argparse.ArgumentParser( + description='Interactive xBot service shell') + parser.add_argument('--bind', default='0.0.0.0', + metavar='IP', + help='bind IP for UDP socket (default: 0.0.0.0)') + args = parser.parse_args(argv) + + shell_mgr = XbotShell(bind_ip=args.bind) + shell_mgr.start() + + _print_banner(args.bind) + + ns: dict[str, Any] = { + 'services': shell_mgr.services, + 'connect': shell_mgr.connect, + 'xbot': shell_mgr, + } + + ipshell = InteractiveShellEmbed( + banner1='', + banner2='', + exit_msg='\nStopping xbot IO...', + user_ns=ns, + ) + + _register_magics(shell_mgr, ipshell, ns) + + try: + ipshell() + finally: + shell_mgr.stop() + + +def _register_magics(shell_mgr: XbotShell, ipshell, ns: dict): + """Register %services and %connect IPython magic commands.""" + + @ipshell.register_magic_function + def services(line): + """%services — list all discovered xBot services.""" + shell_mgr.services() + + @ipshell.register_magic_function + def connect(line): + """%connect [as ] + Connect to an xBot service and inject the proxy into the namespace. + """ + parts = line.strip().split() + if not parts: + print("Usage: %connect [as ]") + return + + raw = parts[0] + arg: Any = int(raw) if raw.isdigit() else raw + + varname = None + if len(parts) >= 3 and parts[1].lower() == 'as': + varname = parts[2] + + try: + proxy = shell_mgr.connect(arg) + except ValueError as e: + _print(f'[red]{e}[/red]', plain=str(e)) + return + + if varname is None: + schema = proxy._iface._active_schema + varname = (schema.type.lower() if schema else f'svc_{arg}') + + ns[varname] = proxy + ipshell.user_ns[varname] = proxy + _print( + f"[green]→[/green] Proxy available as [bold]{varname}[/bold] " + f"(call [bold]{varname}.wait_connected()[/bold] to block until ready)", + plain=f"→ Proxy available as {varname}", + ) + return proxy + + +def _print_banner(bind_ip: str): + if HAS_RICH and _console is not None: + _console.print() + _console.rule('[bold cyan]xBot Service Shell[/bold cyan]') + _console.print() + _console.print(' [bold]services()[/bold] list discovered services') + _console.print(' [bold]connect(id_or_name)[/bold] connect, returns ServiceProxy') + _console.print(' [bold]svc.wait_connected()[/bold] block until service is ready') + _console.print(' [bold]svc.info()[/bold] print schema (inputs/outputs/registers/RPC)') + _console.print(' [bold]svc.watch_all()[/bold] stream output values to console') + _console.print() + _console.print(' Magic commands: [bold]%connect EchoService[/bold] / [bold]%services[/bold]') + _console.print(f' Listening on [dim]{bind_ip}[/dim] (multicast 233.255.255.0:4242)') + _console.print() + _console.rule() + _console.print() + else: + print(f""" +xBot Service Shell +================== + services() list discovered services + connect(id_or_name) connect, returns ServiceProxy + svc.wait_connected() block until service is ready + svc.info() print schema + svc.watch_all() stream outputs to console + + Listening on {bind_ip} (multicast 233.255.255.0:4242) +""") + + +if __name__ == '__main__': + main() From b78fbbae25bc877199147a2865c90a5ea29c8b17 Mon Sep 17 00:00:00 2001 From: Clemens Elflein Date: Tue, 26 May 2026 15:54:52 +0200 Subject: [PATCH 02/11] fix: busy-wait, callback race, dep floors, markdown fences, vcs.xml - lcd_hello.py: restore time.sleep(0.01) to prevent CPU spin - shell.py wait_connected: register callback before checking _connected to close race; one-shot _cb removes itself to prevent accumulation; timeout path also cleans up the callback - pyproject.toml: raise shell extras to ipython>=9.13.0, rich>=15.0.0 - README.md: add `text` language tag to three bare fenced code blocks - .idea/vcs.xml: remove generated cmake-build-debug/_deps VCS mappings --- .idea/vcs.xml | 2 -- xbot_service_interface/README.md | 6 ++--- xbot_service_interface/examples/lcd_hello.py | 2 +- xbot_service_interface/pyproject.toml | 2 +- .../xbot_service_interface/shell.py | 22 ++++++++++++++----- 5 files changed, 22 insertions(+), 12 deletions(-) diff --git a/.idea/vcs.xml b/.idea/vcs.xml index fa5d3f7..43e5888 100644 --- a/.idea/vcs.xml +++ b/.idea/vcs.xml @@ -2,8 +2,6 @@ - - diff --git a/xbot_service_interface/README.md b/xbot_service_interface/README.md index 1dfb50a..6b8af82 100644 --- a/xbot_service_interface/README.md +++ b/xbot_service_interface/README.md @@ -112,7 +112,7 @@ xbot-shell --bind 192.168.1.x # specific interface On startup the shell listens for service advertisements on the multicast group and prints each service as it appears: -``` +```text → Discovered: EchoService (id=1) at 192.168.1.5:4242 — connect(1) ``` @@ -131,7 +131,7 @@ svc.watch('echo') # stream a specific output IPython magic equivalents: -``` +```text %services %connect EchoService %connect 1 as svc @@ -141,7 +141,7 @@ IPython magic equivalents: All service channels are tab-completable once connected: -``` +```text svc.send_ → send_input_text(value) svc.call_ → call_rpc_echo_test(text, echo_count) svc.on_ → on_echo_changed, on_message_count_changed diff --git a/xbot_service_interface/examples/lcd_hello.py b/xbot_service_interface/examples/lcd_hello.py index 1632c39..8ac5dc4 100644 --- a/xbot_service_interface/examples/lcd_hello.py +++ b/xbot_service_interface/examples/lcd_hello.py @@ -148,7 +148,7 @@ def disconnected(): print(f"Update error: {e}") counter += 1 - # time.sleep(0.01) + time.sleep(0.01) except KeyboardInterrupt: pass finally: diff --git a/xbot_service_interface/pyproject.toml b/xbot_service_interface/pyproject.toml index bacf091..1ddea7c 100644 --- a/xbot_service_interface/pyproject.toml +++ b/xbot_service_interface/pyproject.toml @@ -44,4 +44,4 @@ xbot-shell = "xbot_service_interface.shell:main" [project.optional-dependencies] dev = ["pytest>=8"] -shell = ["ipython>=8", "rich>=13", "heatshrink2>=0.5"] +shell = ["ipython>=9.13.0", "rich>=15.0.0", "heatshrink2>=0.5"] diff --git a/xbot_service_interface/xbot_service_interface/shell.py b/xbot_service_interface/xbot_service_interface/shell.py index 5f636a0..c7e8a8b 100644 --- a/xbot_service_interface/xbot_service_interface/shell.py +++ b/xbot_service_interface/xbot_service_interface/shell.py @@ -185,13 +185,25 @@ def __setattr__(self, name: str, value: Any): def wait_connected(self, timeout: float = 10.0) -> bool: """Block until the service is claimed, or timeout seconds pass.""" iface = object.__getattribute__(self, '_iface') - if iface._connected: - _print(f"[green]✓[/green] Already connected", plain="✓ Already connected") - self._warn_unconfigured(iface) - return True ev = threading.Event() - iface.on_connected(lambda: ev.set()) + + def _cb(): + ev.set() + try: + iface._connected_callbacks.remove(_cb) + except ValueError: + pass + + iface.on_connected(_cb) + if iface._connected: # re-check after registering to close the race + ev.set() + ok = ev.wait(timeout=timeout) + if not ok: + try: + iface._connected_callbacks.remove(_cb) + except ValueError: + pass if ok: schema = iface._active_schema name = schema.type if schema else f'service {iface._service_id}' From 4698f8fb34e751fcb879629435aaf1f7c6b9e7a9 Mon Sep 17 00:00:00 2001 From: Clemens Elflein Date: Tue, 26 May 2026 16:08:10 +0200 Subject: [PATCH 03/11] =?UTF-8?q?refactor:=20rename=20xbot=5Fservice=5Fint?= =?UTF-8?q?erface=20=E2=86=92=20xbot=5Fservice=5Finterface=5Fpy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes it unambiguous that this is the Python implementation alongside the C++ libxbot-service-interface. PyPI project name updated to match: xbot-service-interface → xbot-service-interface-py. The importable package (xbot_service_interface) is unchanged. --- .../.gitignore | 0 {xbot_service_interface => xbot_service_interface_py}/README.md | 0 .../examples/echo_no_schema.py | 0 .../examples/echo_service.json | 0 .../examples/echo_with_schema.py | 0 .../examples/gpio.json | 0 .../examples/lcd_hello.py | 0 .../pyproject.toml | 2 +- .../requirements.txt | 0 .../tests/__init__.py | 0 .../tests/conftest.py | 0 .../tests/test_datatypes.py | 0 .../tests/test_discovery.py | 0 .../tests/test_interface.py | 0 .../tests/test_io.py | 0 .../tests/test_rpc.py | 0 .../tests/test_schema.py | 0 .../tests/test_serialization.py | 0 .../xbot_service_interface/__init__.py | 0 .../xbot_service_interface/datatypes.py | 0 .../xbot_service_interface/discovery.py | 0 .../xbot_service_interface/exceptions.py | 0 .../xbot_service_interface/interface.py | 0 .../xbot_service_interface/io.py | 0 .../xbot_service_interface/manager.py | 0 .../xbot_service_interface/py.typed | 0 .../xbot_service_interface/schema.py | 0 .../xbot_service_interface/serialization.py | 0 .../xbot_service_interface/shell.py | 0 29 files changed, 1 insertion(+), 1 deletion(-) rename {xbot_service_interface => xbot_service_interface_py}/.gitignore (100%) rename {xbot_service_interface => xbot_service_interface_py}/README.md (100%) rename {xbot_service_interface => xbot_service_interface_py}/examples/echo_no_schema.py (100%) rename {xbot_service_interface => xbot_service_interface_py}/examples/echo_service.json (100%) rename {xbot_service_interface => xbot_service_interface_py}/examples/echo_with_schema.py (100%) rename {xbot_service_interface => xbot_service_interface_py}/examples/gpio.json (100%) rename {xbot_service_interface => xbot_service_interface_py}/examples/lcd_hello.py (100%) rename {xbot_service_interface => xbot_service_interface_py}/pyproject.toml (97%) rename {xbot_service_interface => xbot_service_interface_py}/requirements.txt (100%) rename {xbot_service_interface => xbot_service_interface_py}/tests/__init__.py (100%) rename {xbot_service_interface => xbot_service_interface_py}/tests/conftest.py (100%) rename {xbot_service_interface => xbot_service_interface_py}/tests/test_datatypes.py (100%) rename {xbot_service_interface => xbot_service_interface_py}/tests/test_discovery.py (100%) rename {xbot_service_interface => xbot_service_interface_py}/tests/test_interface.py (100%) rename {xbot_service_interface => xbot_service_interface_py}/tests/test_io.py (100%) rename {xbot_service_interface => xbot_service_interface_py}/tests/test_rpc.py (100%) rename {xbot_service_interface => xbot_service_interface_py}/tests/test_schema.py (100%) rename {xbot_service_interface => xbot_service_interface_py}/tests/test_serialization.py (100%) rename {xbot_service_interface => xbot_service_interface_py}/xbot_service_interface/__init__.py (100%) rename {xbot_service_interface => xbot_service_interface_py}/xbot_service_interface/datatypes.py (100%) rename {xbot_service_interface => xbot_service_interface_py}/xbot_service_interface/discovery.py (100%) rename {xbot_service_interface => xbot_service_interface_py}/xbot_service_interface/exceptions.py (100%) rename {xbot_service_interface => xbot_service_interface_py}/xbot_service_interface/interface.py (100%) rename {xbot_service_interface => xbot_service_interface_py}/xbot_service_interface/io.py (100%) rename {xbot_service_interface => xbot_service_interface_py}/xbot_service_interface/manager.py (100%) rename {xbot_service_interface => xbot_service_interface_py}/xbot_service_interface/py.typed (100%) rename {xbot_service_interface => xbot_service_interface_py}/xbot_service_interface/schema.py (100%) rename {xbot_service_interface => xbot_service_interface_py}/xbot_service_interface/serialization.py (100%) rename {xbot_service_interface => xbot_service_interface_py}/xbot_service_interface/shell.py (100%) diff --git a/xbot_service_interface/.gitignore b/xbot_service_interface_py/.gitignore similarity index 100% rename from xbot_service_interface/.gitignore rename to xbot_service_interface_py/.gitignore diff --git a/xbot_service_interface/README.md b/xbot_service_interface_py/README.md similarity index 100% rename from xbot_service_interface/README.md rename to xbot_service_interface_py/README.md diff --git a/xbot_service_interface/examples/echo_no_schema.py b/xbot_service_interface_py/examples/echo_no_schema.py similarity index 100% rename from xbot_service_interface/examples/echo_no_schema.py rename to xbot_service_interface_py/examples/echo_no_schema.py diff --git a/xbot_service_interface/examples/echo_service.json b/xbot_service_interface_py/examples/echo_service.json similarity index 100% rename from xbot_service_interface/examples/echo_service.json rename to xbot_service_interface_py/examples/echo_service.json diff --git a/xbot_service_interface/examples/echo_with_schema.py b/xbot_service_interface_py/examples/echo_with_schema.py similarity index 100% rename from xbot_service_interface/examples/echo_with_schema.py rename to xbot_service_interface_py/examples/echo_with_schema.py diff --git a/xbot_service_interface/examples/gpio.json b/xbot_service_interface_py/examples/gpio.json similarity index 100% rename from xbot_service_interface/examples/gpio.json rename to xbot_service_interface_py/examples/gpio.json diff --git a/xbot_service_interface/examples/lcd_hello.py b/xbot_service_interface_py/examples/lcd_hello.py similarity index 100% rename from xbot_service_interface/examples/lcd_hello.py rename to xbot_service_interface_py/examples/lcd_hello.py diff --git a/xbot_service_interface/pyproject.toml b/xbot_service_interface_py/pyproject.toml similarity index 97% rename from xbot_service_interface/pyproject.toml rename to xbot_service_interface_py/pyproject.toml index 1ddea7c..a153ebe 100644 --- a/xbot_service_interface/pyproject.toml +++ b/xbot_service_interface_py/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools>=61", "wheel"] build-backend = "setuptools.build_meta" [project] -name = "xbot-service-interface" +name = "xbot-service-interface-py" version = "0.1.0" description = "Python service interface for the xBot Framework — connect to xBot services over UDP without code generation" readme = "README.md" diff --git a/xbot_service_interface/requirements.txt b/xbot_service_interface_py/requirements.txt similarity index 100% rename from xbot_service_interface/requirements.txt rename to xbot_service_interface_py/requirements.txt diff --git a/xbot_service_interface/tests/__init__.py b/xbot_service_interface_py/tests/__init__.py similarity index 100% rename from xbot_service_interface/tests/__init__.py rename to xbot_service_interface_py/tests/__init__.py diff --git a/xbot_service_interface/tests/conftest.py b/xbot_service_interface_py/tests/conftest.py similarity index 100% rename from xbot_service_interface/tests/conftest.py rename to xbot_service_interface_py/tests/conftest.py diff --git a/xbot_service_interface/tests/test_datatypes.py b/xbot_service_interface_py/tests/test_datatypes.py similarity index 100% rename from xbot_service_interface/tests/test_datatypes.py rename to xbot_service_interface_py/tests/test_datatypes.py diff --git a/xbot_service_interface/tests/test_discovery.py b/xbot_service_interface_py/tests/test_discovery.py similarity index 100% rename from xbot_service_interface/tests/test_discovery.py rename to xbot_service_interface_py/tests/test_discovery.py diff --git a/xbot_service_interface/tests/test_interface.py b/xbot_service_interface_py/tests/test_interface.py similarity index 100% rename from xbot_service_interface/tests/test_interface.py rename to xbot_service_interface_py/tests/test_interface.py diff --git a/xbot_service_interface/tests/test_io.py b/xbot_service_interface_py/tests/test_io.py similarity index 100% rename from xbot_service_interface/tests/test_io.py rename to xbot_service_interface_py/tests/test_io.py diff --git a/xbot_service_interface/tests/test_rpc.py b/xbot_service_interface_py/tests/test_rpc.py similarity index 100% rename from xbot_service_interface/tests/test_rpc.py rename to xbot_service_interface_py/tests/test_rpc.py diff --git a/xbot_service_interface/tests/test_schema.py b/xbot_service_interface_py/tests/test_schema.py similarity index 100% rename from xbot_service_interface/tests/test_schema.py rename to xbot_service_interface_py/tests/test_schema.py diff --git a/xbot_service_interface/tests/test_serialization.py b/xbot_service_interface_py/tests/test_serialization.py similarity index 100% rename from xbot_service_interface/tests/test_serialization.py rename to xbot_service_interface_py/tests/test_serialization.py diff --git a/xbot_service_interface/xbot_service_interface/__init__.py b/xbot_service_interface_py/xbot_service_interface/__init__.py similarity index 100% rename from xbot_service_interface/xbot_service_interface/__init__.py rename to xbot_service_interface_py/xbot_service_interface/__init__.py diff --git a/xbot_service_interface/xbot_service_interface/datatypes.py b/xbot_service_interface_py/xbot_service_interface/datatypes.py similarity index 100% rename from xbot_service_interface/xbot_service_interface/datatypes.py rename to xbot_service_interface_py/xbot_service_interface/datatypes.py diff --git a/xbot_service_interface/xbot_service_interface/discovery.py b/xbot_service_interface_py/xbot_service_interface/discovery.py similarity index 100% rename from xbot_service_interface/xbot_service_interface/discovery.py rename to xbot_service_interface_py/xbot_service_interface/discovery.py diff --git a/xbot_service_interface/xbot_service_interface/exceptions.py b/xbot_service_interface_py/xbot_service_interface/exceptions.py similarity index 100% rename from xbot_service_interface/xbot_service_interface/exceptions.py rename to xbot_service_interface_py/xbot_service_interface/exceptions.py diff --git a/xbot_service_interface/xbot_service_interface/interface.py b/xbot_service_interface_py/xbot_service_interface/interface.py similarity index 100% rename from xbot_service_interface/xbot_service_interface/interface.py rename to xbot_service_interface_py/xbot_service_interface/interface.py diff --git a/xbot_service_interface/xbot_service_interface/io.py b/xbot_service_interface_py/xbot_service_interface/io.py similarity index 100% rename from xbot_service_interface/xbot_service_interface/io.py rename to xbot_service_interface_py/xbot_service_interface/io.py diff --git a/xbot_service_interface/xbot_service_interface/manager.py b/xbot_service_interface_py/xbot_service_interface/manager.py similarity index 100% rename from xbot_service_interface/xbot_service_interface/manager.py rename to xbot_service_interface_py/xbot_service_interface/manager.py diff --git a/xbot_service_interface/xbot_service_interface/py.typed b/xbot_service_interface_py/xbot_service_interface/py.typed similarity index 100% rename from xbot_service_interface/xbot_service_interface/py.typed rename to xbot_service_interface_py/xbot_service_interface/py.typed diff --git a/xbot_service_interface/xbot_service_interface/schema.py b/xbot_service_interface_py/xbot_service_interface/schema.py similarity index 100% rename from xbot_service_interface/xbot_service_interface/schema.py rename to xbot_service_interface_py/xbot_service_interface/schema.py diff --git a/xbot_service_interface/xbot_service_interface/serialization.py b/xbot_service_interface_py/xbot_service_interface/serialization.py similarity index 100% rename from xbot_service_interface/xbot_service_interface/serialization.py rename to xbot_service_interface_py/xbot_service_interface/serialization.py diff --git a/xbot_service_interface/xbot_service_interface/shell.py b/xbot_service_interface_py/xbot_service_interface/shell.py similarity index 100% rename from xbot_service_interface/xbot_service_interface/shell.py rename to xbot_service_interface_py/xbot_service_interface/shell.py From bdbec216d720635c833abce5fe52c31a27540c52 Mon Sep 17 00:00:00 2001 From: Clemens Elflein Date: Tue, 26 May 2026 16:13:10 +0200 Subject: [PATCH 04/11] ci: add Python test/build/publish workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests on 3.10–3.12 for every push. Build wheel+sdist after tests pass. Publish to PyPI on v* tags via OIDC trusted publisher (no API token needed). --- .github/workflows/python.yaml | 59 ++++++++++++++++++++++++ xbot_service_interface_py/pyproject.toml | 3 +- 2 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/python.yaml diff --git a/.github/workflows/python.yaml b/.github/workflows/python.yaml new file mode 100644 index 0000000..3cba27b --- /dev/null +++ b/.github/workflows/python.yaml @@ -0,0 +1,59 @@ +name: python + +on: + pull_request: + push: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: xbot_service_interface_py + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install + run: pip install -e ".[dev]" + - name: Test + run: pytest + + build: + runs-on: ubuntu-latest + needs: test + defaults: + run: + working-directory: xbot_service_interface_py + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Build + run: | + pip install build + python -m build + - uses: actions/upload-artifact@v4 + with: + name: dist + path: xbot_service_interface_py/dist/ + + publish: + runs-on: ubuntu-latest + needs: build + if: startsWith(github.ref, 'refs/tags/v') + environment: pypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/xbot_service_interface_py/pyproject.toml b/xbot_service_interface_py/pyproject.toml index a153ebe..3ab217f 100644 --- a/xbot_service_interface_py/pyproject.toml +++ b/xbot_service_interface_py/pyproject.toml @@ -7,7 +7,7 @@ name = "xbot-service-interface-py" version = "0.1.0" description = "Python service interface for the xBot Framework — connect to xBot services over UDP without code generation" readme = "README.md" -license = { text = "MIT" } +license = "MIT" authors = [{ name = "Clemens Elflein" }] requires-python = ">=3.10" dependencies = ["cbor2>=6.1.1"] @@ -15,7 +15,6 @@ keywords = ["robotics", "xbot", "udp", "service-interface", "embedded"] classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", From cd9bd6c87637441643652961f7013e04ca1bdc03 Mon Sep 17 00:00:00 2001 From: Clemens Elflein Date: Tue, 26 May 2026 16:26:30 +0200 Subject: [PATCH 05/11] docs: rewrite README with full API and IPython shell documentation --- xbot_service_interface_py/README.md | 643 ++++++++++++++++++++++++---- 1 file changed, 548 insertions(+), 95 deletions(-) diff --git a/xbot_service_interface_py/README.md b/xbot_service_interface_py/README.md index 6b8af82..1f1bd8d 100644 --- a/xbot_service_interface_py/README.md +++ b/xbot_service_interface_py/README.md @@ -1,44 +1,42 @@ -# xbot-service-interface +# xbot-service-interface-py -Python interface library for the [xBot Framework](https://github.com/xtech/xbot_framework). -Connect to xBot services running anywhere on your network — no code generation required. +Python client library for [xBot Framework](https://github.com/xtech/xbot_framework) services. Connects to xBot services over UDP without code generation — schema is either loaded from a JSON file or received automatically from the service advertisement. ## Installation ```bash -# Library only -pip install xbot-service-interface - -# Library + interactive shell (IPython + Rich) -pip install "xbot-service-interface[shell]" +pip install xbot-service-interface-py # core library +pip install "xbot-service-interface-py[shell]" # + IPython shell ``` -### From source +Requires Python 3.10+. -```bash -git clone https://github.com/xtech/xbot_framework -cd xbot_framework/xbot_service_interface +--- -# Library only -pip install -e . +## Architecture overview -# With interactive shell -pip install -e ".[shell]" ``` +XbotServiceIo — owns the UDP socket and discovery listener + └── ServiceInterface — represents one remote service + ├── registers — send/read configuration registers + ├── send_*() — send inputs to the service + ├── on_*_changed — receive outputs from the service + └── call_*() — synchronous RPC calls +``` + +`XbotServiceIo` runs two background threads: one for multicast service discovery (UDP 233.255.255.0:4242) and one for the data/control socket. All callbacks are dispatched from those threads. + +--- ## Quick start +### Mode 1 — schema provided (validated) + ```python from xbot_service_interface import XbotServiceIo, ServiceInterface xbot = XbotServiceIo(bind_ip='0.0.0.0') - -# Mode 1: provide schema for type/version validation echo = ServiceInterface(service_id=1, schema='echo_service.json') - -# Mode 2: discover schema automatically from advertisement -echo = ServiceInterface(service_id=1) - xbot.register(echo) @echo.on_connected @@ -48,145 +46,600 @@ def connected(): @echo.on_echo_changed def got_echo(value: str, timestamp: int): - print(f"echo: {value}") + print(f"echo: {value!r} (ts={timestamp}µs)") xbot.start() -echo.send_input_text("hello") - -# Atomic transaction -with echo.transaction(): - echo.send_input_text("hello") - echo.send_other_input(42) +import time +while xbot.ok(): + if echo.connected: + echo.send_input_text("hello") + time.sleep(1.0) xbot.stop() ``` -## Concepts +The schema file is validated against the service advertisement: if type or version mismatches, `IncompatibleServiceError` is logged and the interface is not connected. + +### Mode 2 — schema-free (auto-discovered) + +```python +echo = ServiceInterface(service_id=1) # no schema= argument +xbot.register(echo) + +@echo.on_connected +def connected(): + schema = echo._active_schema + print(schema.type, schema.version) + print([i['name'] for i in schema.inputs]) +``` + +The full service description is embedded in the CBOR advertisement packet. All inputs, outputs, registers and RPC functions are available immediately after connection. + +--- + +## API reference + +### `XbotServiceIo` + +```python +xbot = XbotServiceIo(bind_ip='0.0.0.0') +``` + +| Method | Description | +|--------|-------------| +| `register(iface)` | Register a `ServiceInterface`. Call before or after `start()`. | +| `start()` | Start IO and discovery background threads. | +| `stop()` | Stop all threads gracefully. | +| `ok() → bool` | `True` while the IO thread is running. Use as main-loop condition. | + +`bind_ip` selects the network interface for the UDP socket and multicast group membership. Use `'0.0.0.0'` to listen on all interfaces. -### Two modes +--- -| Mode | Usage | Behaviour | -|------|-------|-----------| -| **Mode 1** — schema provided | `ServiceInterface(service_id=1, schema='svc.json')` | Validates advertised `type` + `version` against your schema on connect. Raises `IncompatibleServiceError` on mismatch. | -| **Mode 2** — no schema | `ServiceInterface(service_id=1)` | Accepts the first service with matching ID. Uses the full schema embedded in the advertisement (inputs, outputs, registers, enums all available). | +### `ServiceInterface` -### Dynamic API +```python +iface = ServiceInterface(service_id: int, schema=None) +``` -Channels from the service JSON are available as Python attributes at runtime: +`schema` accepts: a file path (`str` or `Path`), a `dict`, a `ServiceSchema` instance, or `None` for schema-free mode. + +#### Lifecycle ```python -# Inputs (interface → service): send_{snake_name}(value) -echo.send_input_text("hello") -echo.send_input[0]("hello") # by id +@iface.on_connected +def handler(): + ... + +@iface.on_disconnected +def handler(): + ... +``` -# Outputs (service → interface): on_{snake_name}_changed -echo.on_echo_changed = my_callback # assignment -@echo.on_echo_changed # decorator -def my_callback(value, timestamp): ... -echo.on_output[0] = my_callback # by id +Both can also be used as direct calls: `iface.on_connected(my_fn)`. Callbacks fire in a dedicated daemon thread so they can safely call RPC functions or block without deadlocking the IO thread. + +```python +iface.connected # bool property — True once CLAIM_ACK received ``` -Name mapping: `"Input Text"` → `send_input_text`, `"Message Count"` → `on_message_count_changed`. +#### Sending inputs -### Registers +Inputs are sent by calling `send_{snake_name}(value)`: ```python -echo.registers['Prefix'] = "hello: " # sent on connect / immediately if connected -echo.registers['EchoCount'] = 2 -val = echo.registers['Prefix'] +iface.send_input_text("hello") # char[] input named "InputText" +iface.send_target_speed(1.5) # float input named "TargetSpeed" ``` -All registers are sent as a single configuration transaction (required by the xBot protocol). +The attribute is resolved dynamically against the active schema. Calling it before the service is connected raises `RuntimeError`. + +#### Receiving outputs + +Assign a callable to `on_{snake_name}_changed`: -## Interactive shell +```python +# Assignment style +iface.on_echo_changed = lambda value, ts: print(value) + +# Decorator style +@iface.on_echo_changed +def handler(value, timestamp: int): + ... +``` -`xbot-shell` is an IPython-based REPL for exploring and testing services live. -Requires the `[shell]` extras (`ipython`, `rich`). +The callback receives `(value, timestamp)` where `timestamp` is in microseconds. Callbacks can be registered before discovery — they are wired automatically once the schema is known. + +Output callbacks can also be registered by numeric channel ID before the schema is available: + +```python +iface.on_output[3] = my_callback +``` + +#### Registers + +Registers configure the service. They are sent as a transaction the moment one is written while connected; if not connected yet, they are stored and sent on the next `CONFIGURATION_REQUEST`. + +```python +iface.registers['Prefix'] = "hello: " # by original name +iface.registers['echo_count'] = 2 # by snake_case name +iface.registers['EchoCount'] = 2 # any capitalisation works +``` + +The service resets **all** registers to defaults before applying a configuration transaction, so the library always sends all stored registers together — not just the changed one. + +#### Atomic transactions + +Multiple inputs can be bundled into a single UDP packet: + +```python +with iface.transaction(): + iface.send_x(1.0) + iface.send_y(2.0) + iface.send_z(3.0) +``` + +If an exception is raised inside the block the transaction is discarded. Only one transaction can be active at a time per interface. + +#### RPC calls + +RPC functions are called synchronously as `call_{snake_name}(*args, timeout_ms=1000)`: + +```python +result = iface.call_rpc_echo_test("hi", 3) # default 1000ms timeout +result = iface.call_rpc_echo_test("hi", 3, timeout_ms=500) +``` + +The call blocks until a response is received or the timeout expires. Raises: + +| Exception | Cause | +|-----------|-------| +| `RpcTimeoutError` | No response within `timeout_ms` | +| `RpcBusyError` | Service reports another call is already in progress | +| `RpcError(status)` | Service returned a non-zero status code | +| `TypeError` | Wrong number of arguments | +| `RuntimeError` | Not connected, or call failed to send | + +Only one in-flight RPC call per interface is allowed at a time. + +#### Low-level by-ID access + +```python +iface.send_input[channel_id](value) # send by numeric channel id +iface.on_output[channel_id] = callback # register output by channel id +``` + +--- + +### `ServiceSchema` + +Parsed service description. Normally created automatically from discovery or from the `schema=` argument. + +```python +schema = ServiceSchema.from_file('echo_service.json') +schema = ServiceSchema.from_dict({"type": "EchoService", "version": 1, ...}) + +schema.type # str +schema.version # int +schema.inputs # list of channel dicts +schema.outputs # list of channel dicts +schema.registers # list of register dicts +schema.functions # list of function dicts +schema.enums_dict # dict of enum definitions + +schema.get_input('InputText') # by name, snake_name, or numeric id +schema.get_output(3) +schema.get_register('Prefix') +schema.get_function('RpcEchoTest') +schema.is_compatible(advertised_desc: dict) -> bool +``` + +Channel dicts contain: `id`, `name`, `snake_name`, `type_str`, `base_type`, `is_array`, `max_len`. Register dicts additionally have `optional` (bool) and optionally `default`. + +#### Schema JSON format + +```json +{ + "type": "EchoService", + "version": 1, + "inputs": [ + {"id": 1, "name": "InputText", "type": "char[100]"} + ], + "outputs": [ + {"id": 1, "name": "Echo", "type": "char[100]"}, + {"id": 2, "name": "MessageCount", "type": "uint32_t"} + ], + "registers": [ + {"id": 1, "name": "Prefix", "type": "char[20]", "optional": false}, + {"id": 2, "name": "EchoCount", "type": "uint32_t", "optional": true} + ], + "functions": [ + { + "id": 1, "name": "RpcEchoTest", + "return_type": "char[30]", + "parameters": [ + {"id": 1, "name": "Text", "type": "char[10]"}, + {"id": 2, "name": "EchoCount", "type": "uint32_t"} + ] + } + ], + "enums": [ + { + "id": "GpioMode", + "base_type": "uint8_t", + "bitmask": false, + "values": {"INPUT": 0, "OUTPUT": 1, "INPUT_PULLUP": 2} + } + ] +} +``` + +#### Type system + +| Type string | Python type | +|-------------|-------------| +| `uint8_t` … `uint64_t` | `int` | +| `int8_t` … `int64_t` | `int` | +| `float`, `double` | `float` | +| `bool` | `bool` | +| `char[N]` | `str` | +| `uint8_t[N]` etc. | `list[int]` | +| `float[N]` / `double[N]` | `list[float]` | +| `blob` | `bytes` | +| enum name | `str` (value name) or `int` | + +--- + +### Exceptions + +```python +from xbot_service_interface.exceptions import ( + IncompatibleServiceError, # schema type/version mismatch on discovery + UnknownChannelError, # unknown input/output/register/function name or id + RpcError, # RPC non-zero status (.status attribute) + RpcBusyError, # RPC busy (status=1) + RpcTimeoutError, # RPC timeout (inherits TimeoutError) +) +``` + +--- + +### Logging + +The library uses standard `logging` under the `xbot_service_interface` hierarchy. Enable with: + +```python +import logging +logging.basicConfig(level=logging.INFO) +``` + +Use `logging.DEBUG` to see per-packet detail including RPC round-trips and discovery events. + +--- + +## IPython shell + +`xbot-shell` is an IPython-based interactive shell for exploring and controlling xBot services without writing any code. It requires the `[shell]` extras (`ipython`, `rich`). ```bash -xbot-shell # bind all interfaces -xbot-shell --bind 192.168.1.x # specific interface +xbot-shell # listen on all interfaces +xbot-shell --bind 192.168.1.5 # listen on a specific interface ``` -On startup the shell listens for service advertisements on the multicast group -and prints each service as it appears: +On startup the shell prints a command reference and begins listening for service advertisements. Each new service is announced automatically: ```text → Discovered: EchoService (id=1) at 192.168.1.5:4242 — connect(1) ``` -### Shell commands +### Built-in functions + +Two functions are injected into the IPython namespace. + +#### `services()` + +Print a rich table of all discovered services with endpoint, channel counts, connection and configuration status. ```python -services() # list all discovered services -svc = connect(1) # connect by service ID -svc = connect("EchoService") # or by type name +In [1]: services() +``` -svc.wait_connected() # block until the service is claimed (default 10 s) -svc.info() # print schema — inputs, outputs, registers, RPC functions -svc.watch_all() # stream every output value to the console -svc.watch('echo') # stream a specific output +```text + Discovered Services + ID Type Endpoint In Out Fn Status Config + 1 EchoService 192.168.1.5:4242 1 2 1 connected configured + 2 GpioService 192.168.1.6:4242 8 8 0 ``` -IPython magic equivalents: +#### `connect(id_or_name) → ServiceProxy` + +Connect to a service by numeric ID or type name. Returns a `ServiceProxy` immediately — connection handshake runs in the background. + +```python +svc = connect(1) +svc = connect("EchoService") # case-insensitive type name match +``` + +Call `wait_connected()` on the result to block until the service is ready before sending any data: + +```python +svc.wait_connected() # blocks up to 10 s (default) +svc.wait_connected(timeout=30) # custom timeout in seconds +``` + +Calling `connect()` a second time for the same service returns the existing proxy. + +### Magic commands + +IPython line-magic equivalents that also inject the proxy directly into the namespace: ```text %services +%connect 1 %connect EchoService -%connect 1 as svc +%connect EchoService as echo ``` -### Tab completion +Without `as `, the variable name is derived from the service type in lowercase (e.g. `echoservice`). With `as ` it uses that name exactly. + +### `ServiceProxy` + +The object returned by `connect()`. All attributes are tab-completable. + +#### Introspection -All service channels are tab-completable once connected: +```python +svc.info() # print rich tables: inputs, outputs, registers, RPC functions +repr(svc) # one-line summary with connection status +``` + +`svc.info()` example output: ```text -svc.send_ → send_input_text(value) -svc.call_ → call_rpc_echo_test(text, echo_count) -svc.on_ → on_echo_changed, on_message_count_changed -svc.registers. → prefix, echo_count +EchoService v1 (id=1) connected configured + + Inputs + method type + send_input_text(value) char[100] + + Outputs + callback attribute type + on_echo_changed char[100] + on_message_count_changed uint32_t + + Registers + registers.name type value req + prefix char[20] 'py: ' * + echo_count uint32_t 2 + + RPC Functions + method parameters returns + call_rpc_echo_test(...) text: char[10], echo_count: uint32_t char[30] +``` + +`*` in the req column means the register is required (not optional). + +#### Sending inputs + +Tab-complete `svc.send_` to see all inputs: + +```python +svc.send_input_text("hello") +svc.send_target_speed(1.5) +``` + +Raises `RuntimeError` if not connected. + +#### Receiving outputs + +Tab-complete `svc.on_` to see all output callbacks: + +```python +svc.on_echo_changed = lambda value, ts: print(value) + +@svc.on_echo_changed +def handler(value, timestamp): + print(f"{value!r} ts={timestamp}µs") ``` -Pressing `?` on a method shows the full parameter signature: +#### Live output streaming + +```python +svc.watch_all() # stream every output to console +svc.watch('echo') # stream a single output +svc.watch('echo', 'message_count') # stream multiple outputs +``` + +Console format: + +```text + 12345ms echo = 'py: hello' + 12346ms message_count = 7 +``` + +#### RPC calls + +Tab-complete `svc.call_` to list all RPC functions. Append `?` to see the full signature: ```python svc.call_rpc_echo_test? # RpcEchoTest(text: char[10], echo_count: uint32_t) -> char[30] +# RPC call. Optional keyword: timeout_ms (default 1000). + +result = svc.call_rpc_echo_test("hi", 3) +result = svc.call_rpc_echo_test("hi", 3, timeout_ms=500) +``` + +#### Registers + +Tab-complete `svc.registers.` to see all register names: + +```python +# Read +print(svc.registers.prefix) +print(svc.registers['Prefix']) + +# Write — sent immediately if connected +svc.registers.prefix = "hello: " +svc.registers.echo_count = 2 +svc.registers['Prefix'] = "hello: " + +# Pretty-print all registers with current values +print(svc.registers) +``` + +#### Interactive register wizard + +```python +svc.configure_registers() +``` + +Walks through every register interactively, showing the current value, type, and an input hint. Rules: + +- Press Enter to keep the current value unchanged. +- Type the new value and press Enter to update. +- Type `-` to clear an optional register. +- Required registers (marked `*`) cannot be cleared. +- Numeric arrays accept `1, 2, 3` or `[1, 2, 3]` notation. +- Enum registers show the valid names: `one of: INPUT, OUTPUT, INPUT_PULLUP`. +- `blob` registers accept a file path and optionally compress with heatshrink. + +Example session: + +```text +Configuring registers for EchoService (Enter = keep current, '-' = clear optional) + + prefix char[20] * + current= new value: py: + ✓ set to 'py:' + echo_count uint32_t (optional) + current= new value: 2 + ✓ set to 2 + +✓ Configuration sent. +``` + +For `blob` registers: + +```text + firmware blob * hint: path to file (will ask about heatshrink compression) + current= file path: /tmp/firmware.bin + heatshrink compress? [y/N]: y + ✓ set to 4096 bytes ``` -### Example session +Heatshrink uses window=9, lookahead=5 — matching the C++ firmware defaults. Requires `heatshrink2` (`pip install "xbot-service-interface-py[shell]"` includes it). + +#### Atomic transactions + +```python +with svc.transaction(): + svc.send_x(1.0) + svc.send_y(2.0) + svc.send_z(3.0) +``` + +#### Connection status + +```python +svc.connected # bool +svc.wait_connected(timeout=10) # blocks, returns bool, prints status +``` + +`wait_connected()` also warns if required registers are not yet configured: + +```text +✓ Connected to EchoService +⚠ Required registers not set: prefix, echo_count — call configure_registers() +``` + +### Full shell session example ```python In [1]: services() -# ┌────┬─────────────┬──────────────────┬─────┬─────┬────┐ -# │ ID │ Type │ Endpoint │ In │ Out │ Fn │ -# ├────┼─────────────┼──────────────────┼─────┼─────┼────┤ -# │ 1 │ EchoService │ 192.168.1.5:4242 │ 1 │ 2 │ 3 │ -# └────┴─────────────┴──────────────────┴─────┴─────┴────┘ +# table shows EchoService id=1 In [2]: svc = connect(1) +# Connecting to EchoService (id=1)... call wait_connected() on result to block until ready. + In [3]: svc.wait_connected() # ✓ Connected to EchoService +# ⚠ Required registers not set: prefix, echo_count — call configure_registers() +Out[3]: True -In [4]: svc.registers.prefix = "py: " -In [5]: svc.registers.echo_count = 2 +In [4]: svc.configure_registers() +# ... interactive wizard ... +# ✓ Configuration sent. -In [6]: svc.watch_all() +In [5]: svc.watch_all() # Watching: echo, message_count -In [7]: svc.send_input_text("hello") -# 1234567ms echo = 'py: hello' -# 1234567ms message_count = 1 +In [6]: svc.send_input_text("hello") +# 12345ms echo = 'py: hello' +# 12345ms message_count = 1 -In [8]: svc.call_rpc_echo_test("hi", 3) -# 'py: hihihi' +In [7]: result = svc.call_rpc_echo_test("hi", 3) +In [8]: result +Out[8]: 'hi hi hi' ``` -## Requirements +### Magic-command session + +```text +%connect EchoService as svc +svc.wait_connected() +svc.configure_registers() +svc.watch_all() +``` + +--- + +## Programmatic usage notes + +### Subscribing to outputs before discovery -- Python 3.10+ -- Linux (uses `SIOCGIFADDR` for IP detection, UDP multicast for discovery) -- [`cbor2`](https://pypi.org/project/cbor2/) -- [`ipython`](https://pypi.org/project/ipython/) ≥ 9 (shell only) -- [`rich`](https://pypi.org/project/rich/) ≥ 15 (shell only) +```python +xbot = XbotServiceIo() +echo = ServiceInterface(service_id=1) +xbot.register(echo) + +# Registered before start() — wired automatically on discovery +@echo.on_echo_changed +def got_echo(value, ts): + print(value) + +xbot.start() +``` + +### Reconnection + +On disconnect, `XbotServiceIo` drops the service from its internal table. The next advertisement from the same service ID triggers a fresh connection flow, calling `on_connected` again. No reconnection logic is needed in application code. + +```python +@echo.on_disconnected +def disconnected(): + print("Lost connection — will reconnect automatically on next advertisement") +``` + +### Multiple services + +```python +xbot = XbotServiceIo() +echo = ServiceInterface(service_id=1, schema='echo_service.json') +gpio = ServiceInterface(service_id=2, schema='gpio_service.json') +xbot.register(echo) +xbot.register(gpio) +xbot.start() +``` + +Each `ServiceInterface` connects and disconnects independently. + +--- + +## Development + +```bash +cd xbot_service_interface_py +pip install -e ".[dev]" +pytest +``` From f0500bb9cd14a51b47fd26ce425589a0ee5d2939 Mon Sep 17 00:00:00 2001 From: Clemens Elflein Date: Tue, 26 May 2026 16:31:21 +0200 Subject: [PATCH 06/11] fix: capture DEBUG log level in missing-register test --- xbot_service_interface_py/tests/test_interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xbot_service_interface_py/tests/test_interface.py b/xbot_service_interface_py/tests/test_interface.py index ff4589a..fa498e3 100644 --- a/xbot_service_interface_py/tests/test_interface.py +++ b/xbot_service_interface_py/tests/test_interface.py @@ -374,7 +374,7 @@ def test_missing_required_register_warns(self, caplog): si = make_si(connected=True) # Don't set Prefix (required) si._register_values['EchoCount'] = 1 - with caplog.at_level(logging.WARNING, logger='xbot_service_interface.interface'): + with caplog.at_level(logging.DEBUG, logger='xbot_service_interface.interface'): si._on_config_request() assert 'Prefix' in caplog.text From 7524b6532323de9f2dea57cba8aabd74d693639c Mon Sep 17 00:00:00 2001 From: Clemens Elflein Date: Tue, 26 May 2026 16:35:25 +0200 Subject: [PATCH 07/11] docs: fix arch diagram fence tag, clarify threading and schema validation behavior --- xbot_service_interface_py/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/xbot_service_interface_py/README.md b/xbot_service_interface_py/README.md index 1f1bd8d..f5e56a1 100644 --- a/xbot_service_interface_py/README.md +++ b/xbot_service_interface_py/README.md @@ -15,7 +15,7 @@ Requires Python 3.10+. ## Architecture overview -``` +```text XbotServiceIo — owns the UDP socket and discovery listener └── ServiceInterface — represents one remote service ├── registers — send/read configuration registers @@ -24,7 +24,7 @@ XbotServiceIo — owns the UDP socket and discovery listener └── call_*() — synchronous RPC calls ``` -`XbotServiceIo` runs two background threads: one for multicast service discovery (UDP 233.255.255.0:4242) and one for the data/control socket. All callbacks are dispatched from those threads. +`XbotServiceIo` runs two background threads: one for multicast service discovery (UDP 233.255.255.0:4242) and one for the data/control socket. Output and data callbacks (`on_*_changed`) are dispatched directly from those IO/discovery threads. Lifecycle callbacks (`on_connected`, `on_disconnected`) are dispatched on separate short-lived daemon threads so they can safely call RPC functions or block without deadlocking the IO thread. --- @@ -59,7 +59,7 @@ while xbot.ok(): xbot.stop() ``` -The schema file is validated against the service advertisement: if type or version mismatches, `IncompatibleServiceError` is logged and the interface is not connected. +The schema file is validated against the service advertisement on discovery. If the advertised type or version does not match, `IncompatibleServiceError` is raised, the connection attempt is aborted, and the interface will not connect. ### Mode 2 — schema-free (auto-discovered) From 459f32c194a4688735c25be4ab0f89ce37094fac Mon Sep 17 00:00:00 2001 From: Clemens Elflein Date: Tue, 26 May 2026 16:36:40 +0200 Subject: [PATCH 08/11] fix: remove _cb from on_connected callbacks when already connected in wait_connected --- xbot_service_interface_py/xbot_service_interface/shell.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/xbot_service_interface_py/xbot_service_interface/shell.py b/xbot_service_interface_py/xbot_service_interface/shell.py index c7e8a8b..cab88a1 100644 --- a/xbot_service_interface_py/xbot_service_interface/shell.py +++ b/xbot_service_interface_py/xbot_service_interface/shell.py @@ -197,6 +197,10 @@ def _cb(): iface.on_connected(_cb) if iface._connected: # re-check after registering to close the race ev.set() + try: + iface._connected_callbacks.remove(_cb) + except ValueError: + pass ok = ev.wait(timeout=timeout) if not ok: From 675652bf9cd6dc28efc89ce92dd42d134ba30221 Mon Sep 17 00:00:00 2001 From: Clemens Elflein Date: Tue, 26 May 2026 16:39:06 +0200 Subject: [PATCH 09/11] ci,fix: pin action SHAs, add least-privilege permissions, fix package name in shell error, bump setuptools>=77 for SPDX license --- .github/workflows/python.yaml | 22 +++++++++++++------ xbot_service_interface_py/pyproject.toml | 2 +- .../xbot_service_interface/shell.py | 2 +- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/.github/workflows/python.yaml b/.github/workflows/python.yaml index 3cba27b..3479f73 100644 --- a/.github/workflows/python.yaml +++ b/.github/workflows/python.yaml @@ -5,6 +5,9 @@ on: push: workflow_dispatch: +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest @@ -15,8 +18,10 @@ jobs: matrix: python-version: ["3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: ${{ matrix.python-version }} - name: Install @@ -31,15 +36,17 @@ jobs: run: working-directory: xbot_service_interface_py steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" - name: Build run: | pip install build python -m build - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: dist path: xbot_service_interface_py/dist/ @@ -51,9 +58,10 @@ jobs: environment: pypi permissions: id-token: write + contents: read steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: dist path: dist/ - - uses: pypa/gh-action-pypi-publish@release/v1 + - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 diff --git a/xbot_service_interface_py/pyproject.toml b/xbot_service_interface_py/pyproject.toml index 3ab217f..2c6fa6f 100644 --- a/xbot_service_interface_py/pyproject.toml +++ b/xbot_service_interface_py/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=61", "wheel"] +requires = ["setuptools>=77", "wheel"] build-backend = "setuptools.build_meta" [project] diff --git a/xbot_service_interface_py/xbot_service_interface/shell.py b/xbot_service_interface_py/xbot_service_interface/shell.py index cab88a1..2451ee5 100644 --- a/xbot_service_interface_py/xbot_service_interface/shell.py +++ b/xbot_service_interface_py/xbot_service_interface/shell.py @@ -790,7 +790,7 @@ def connect(self, id_or_name) -> ServiceProxy: def main(argv=None): if not HAS_IPYTHON: print("IPython not installed. Install the shell extras:") - print(" pip install xbot-service-interface[shell]") + print(" pip install xbot-service-interface-py[shell]") sys.exit(1) import argparse From cfdcba2ecd2ebed612178e5dc910627856f33a9e Mon Sep 17 00:00:00 2001 From: Clemens Elflein Date: Tue, 26 May 2026 17:04:28 +0200 Subject: [PATCH 10/11] Fix minor stuff in example --- xbot_service_interface_py/examples/lcd_hello.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/xbot_service_interface_py/examples/lcd_hello.py b/xbot_service_interface_py/examples/lcd_hello.py index 8ac5dc4..39414d0 100644 --- a/xbot_service_interface_py/examples/lcd_hello.py +++ b/xbot_service_interface_py/examples/lcd_hello.py @@ -21,7 +21,7 @@ # ── Config ──────────────────────────────────────────────────────────────────── -BIND_IP = '172.16.78.151' +BIND_IP = '0.0.0.0' GPIO_JSON = Path(__file__).parent / 'gpio.json' LCD_ADDR = 0x27 # PCF8574 I2C address — change to 0x3F if needed I2C_BUS = 0 # matches "id": 0 in gpio.json @@ -148,7 +148,7 @@ def disconnected(): print(f"Update error: {e}") counter += 1 - time.sleep(0.01) + time.sleep(1.0) except KeyboardInterrupt: pass finally: From ca2615999846aadc29852e974229e59586a7d97f Mon Sep 17 00:00:00 2001 From: Clemens Elflein Date: Tue, 26 May 2026 17:13:31 +0200 Subject: [PATCH 11/11] ci(python): publish to GitHub Releases instead of PyPI PyPI account not yet activated. Releases now upload dist artifacts and include ready-to-copy pip install commands in the release body. README updated with git+https install instructions until PyPI is live. --- .github/workflows/python.yaml | 17 +++++++++++++---- xbot_service_interface_py/README.md | 19 +++++++++++++++++-- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/.github/workflows/python.yaml b/.github/workflows/python.yaml index 3479f73..2630863 100644 --- a/.github/workflows/python.yaml +++ b/.github/workflows/python.yaml @@ -55,13 +55,22 @@ jobs: runs-on: ubuntu-latest needs: build if: startsWith(github.ref, 'refs/tags/v') - environment: pypi permissions: - id-token: write - contents: read + contents: write steps: - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: dist path: dist/ - - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + TAG="${{ github.ref_name }}" + REPO="${{ github.repository }}" + printf '## Install\n\n```bash\npip install git+https://github.com/%s.git@%s#subdirectory=xbot_service_interface_py\n# with IPython shell extras:\npip install "git+https://github.com/%s.git@%s#subdirectory=xbot_service_interface_py#egg=xbot-service-interface-py[shell]"\n```\n' \ + "$REPO" "$TAG" "$REPO" "$TAG" > /tmp/release_notes.md + gh release create "$TAG" dist/* \ + --title "xbot-service-interface-py $TAG" \ + --notes-file /tmp/release_notes.md diff --git a/xbot_service_interface_py/README.md b/xbot_service_interface_py/README.md index f5e56a1..69c3bcb 100644 --- a/xbot_service_interface_py/README.md +++ b/xbot_service_interface_py/README.md @@ -4,11 +4,26 @@ Python client library for [xBot Framework](https://github.com/xtech/xbot_framewo ## Installation +Install directly from a tagged GitHub release (replace `v0.1.0` with the desired version): + +```bash +pip install git+https://github.com/xtech/xbot_framework.git@v0.1.0#subdirectory=xbot_service_interface_py +``` + +With the optional IPython shell extras: + ```bash -pip install xbot-service-interface-py # core library -pip install "xbot-service-interface-py[shell]" # + IPython shell +pip install "git+https://github.com/xtech/xbot_framework.git@v0.1.0#subdirectory=xbot_service_interface_py#egg=xbot-service-interface-py[shell]" ``` +To install the latest unreleased version from `main`: + +```bash +pip install git+https://github.com/xtech/xbot_framework.git#subdirectory=xbot_service_interface_py +``` + +> **PyPI:** `pip install xbot-service-interface-py` will be available once the package is published there. + Requires Python 3.10+. ---