From db226553579b3c8c55490ccc2f263fc9ce4f9f9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Ebeling?= Date: Fri, 4 Sep 2026 15:04:15 +0200 Subject: [PATCH 01/11] feat(codegen): add support for enum to_string function generation --- codegen/xbot_codegen/xbot_codegen.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/codegen/xbot_codegen/xbot_codegen.py b/codegen/xbot_codegen/xbot_codegen.py index 657395e..71c1025 100644 --- a/codegen/xbot_codegen/xbot_codegen.py +++ b/codegen/xbot_codegen/xbot_codegen.py @@ -117,6 +117,7 @@ def loadService(path: str) -> dict: "base_type": enum["base_type"], "values": enum["values"], "bitmask": enum.get("bitmask", False), + "to_string": enum.get("to_string", False), }) # Transform the input definitions @@ -240,3 +241,17 @@ def generateEnums(service): for id, value in enum["values"].items(): cog.outl(f" {id} = {value},") cog.outl("};\n") + + if enum["to_string"]: + cog.outl(f"inline const char* {enum['id']}_to_string({enum['id']} v) {{") + cog.outl(" switch (v) {") + seen = set() + for name, value in enum["values"].items(): + if value in seen: + continue + seen.add(value) + cog.outl(f" case {enum['id']}::{name}: return \"{name.lower()}\";") + cog.outl(" default: return \"\";") + cog.outl(" }") + cog.outl("}") + From 43e6c778a6e1f9d267286bf32884f55759ded198 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Ebeling?= Date: Fri, 4 Sep 2026 15:22:39 +0200 Subject: [PATCH 02/11] feat(codegen): add enum count constant for to_string enums --- codegen/xbot_codegen/xbot_codegen.py | 1 + 1 file changed, 1 insertion(+) diff --git a/codegen/xbot_codegen/xbot_codegen.py b/codegen/xbot_codegen/xbot_codegen.py index 71c1025..b9db06c 100644 --- a/codegen/xbot_codegen/xbot_codegen.py +++ b/codegen/xbot_codegen/xbot_codegen.py @@ -243,6 +243,7 @@ def generateEnums(service): cog.outl("};\n") if enum["to_string"]: + cog.outl(f"inline constexpr size_t {enum['id']}_count = {max(enum['values'].values()) + 1};") cog.outl(f"inline const char* {enum['id']}_to_string({enum['id']} v) {{") cog.outl(" switch (v) {") seen = set() From 5607dd8ffe652361c20d6429d1ac386b981ce0b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Ebeling?= Date: Wed, 28 Oct 2026 15:33:02 +0100 Subject: [PATCH 03/11] feat(go): add Go service interface client with CI workflow Add a new `xbot_service_interface_go` package implementing a Go client for the xbot wire protocol (discovery, claim, RPC calls, and DATA messages) without code generation or cgo, depending only on fxamacker/cbor. Includes heatshrink compression support, typed argument helpers, and comprehensive tests. Add a GitHub Actions workflow to run gofmt, go vet, race-enabled tests, and build checks on the Go package, and update the Readme with Go Interface usage documentation. --- .github/workflows/go.yaml | 38 ++ Readme.md | 68 ++++ xbot_service_interface_go/README.md | 143 ++++++++ .../examples/discover/main.go | 43 +++ .../examples/echo/main.go | 70 ++++ xbot_service_interface_go/go.mod | 7 + xbot_service_interface_go/go.sum | 4 + xbot_service_interface_go/xbot/conn.go | 332 ++++++++++++++++++ xbot_service_interface_go/xbot/conn_test.go | 225 ++++++++++++ xbot_service_interface_go/xbot/discovery.go | 259 ++++++++++++++ .../xbot/discovery_test.go | 102 ++++++ xbot_service_interface_go/xbot/doc.go | 24 ++ xbot_service_interface_go/xbot/errors.go | 51 +++ xbot_service_interface_go/xbot/header.go | 200 +++++++++++ xbot_service_interface_go/xbot/header_test.go | 100 ++++++ xbot_service_interface_go/xbot/heatshrink.go | 94 +++++ .../xbot/heatshrink_test.go | 102 ++++++ xbot_service_interface_go/xbot/spec_test.go | 152 ++++++++ 18 files changed, 2014 insertions(+) create mode 100644 .github/workflows/go.yaml create mode 100644 xbot_service_interface_go/README.md create mode 100644 xbot_service_interface_go/examples/discover/main.go create mode 100644 xbot_service_interface_go/examples/echo/main.go create mode 100644 xbot_service_interface_go/go.mod create mode 100644 xbot_service_interface_go/go.sum create mode 100644 xbot_service_interface_go/xbot/conn.go create mode 100644 xbot_service_interface_go/xbot/conn_test.go create mode 100644 xbot_service_interface_go/xbot/discovery.go create mode 100644 xbot_service_interface_go/xbot/discovery_test.go create mode 100644 xbot_service_interface_go/xbot/doc.go create mode 100644 xbot_service_interface_go/xbot/errors.go create mode 100644 xbot_service_interface_go/xbot/header.go create mode 100644 xbot_service_interface_go/xbot/header_test.go create mode 100644 xbot_service_interface_go/xbot/heatshrink.go create mode 100644 xbot_service_interface_go/xbot/heatshrink_test.go create mode 100644 xbot_service_interface_go/xbot/spec_test.go diff --git a/.github/workflows/go.yaml b/.github/workflows/go.yaml new file mode 100644 index 0000000..20eb364 --- /dev/null +++ b/.github/workflows/go.yaml @@ -0,0 +1,38 @@ +name: go + +on: + pull_request: + push: + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: xbot_service_interface_go + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version-file: xbot_service_interface_go/go.mod + cache-dependency-path: xbot_service_interface_go/go.sum + - name: Format + run: | + unformatted="$(gofmt -l .)" + if [ -n "$unformatted" ]; then + echo "gofmt needed for:" + echo "$unformatted" + exit 1 + fi + - name: Vet + run: go vet ./... + - name: Test + run: go test ./... -race -count=1 + - name: Build examples + run: go build ./... diff --git a/Readme.md b/Readme.md index 36512d1..bc1e3a1 100644 --- a/Readme.md +++ b/Readme.md @@ -65,6 +65,12 @@ Python service interface — connect to xBot services from Python without any co [Python Interface](#python-interface) below for a summary, or the [package README](xbot_service_interface_py/README.md) for full details. +### /xbot_service_interface_go + +Go service interface — discovery, claim, RPC calls and DATA messages from Go, without any code generation. See +[Go Interface](#go-interface) below for a summary, or the +[package README](xbot_service_interface_go/README.md) for full details. + --- ## Wire Protocol @@ -313,6 +319,68 @@ Requires Python 3.10+. Full API reference, type mapping table, and shell walkthr --- +## Go Interface + +`xbot_service_interface_go` is a Go client for the same protocol — no code generation, no cgo, one dependency +(`fxamacker/cbor` for the discovery payloads). It covers what a small tool or bridge needs: discovery, claim, +RPC calls and DATA messages. + +```bash +# in your module +go get github.com/xtech/xbot_framework/xbot_service_interface_go@v0.1.0 +``` + +```go +import "github.com/xtech/xbot_framework/xbot_service_interface_go/xbot" + +// Discover (a service that is claimed advertises every 10 s, so allow a window) +ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) +defer cancel() +services, err := xbot.Discover(ctx, "") + +// Or connect straight away; RPC against the EchoService example +conn, err := xbot.Dial(ctx, xbot.Options{ServiceID: 1}) +defer conn.Close() +reply, err := conn.Call(ctx, 2, xbot.Bytes(0, text), xbot.Uint32(1, 3)) +if errors.Is(err, xbot.ErrBusy) { /* the service is still executing another call */ } +``` + +Services are addressed as in the C++/Python side: a `service.json` names the function, input and register ids, and +the Go client sends those ids with typed helpers (`xbot.String`, `xbot.Uint32`, `xbot.Bool`, `xbot.Bytes`) or raw +payloads. Heatshrink-compressed blob registers use `xbot.HeatshrinkEncode`. + +Install a specific version by tag (`xbot_service_interface_go/v0.1.0`) or the latest commit with `@main`. Full API +reference and examples in the [package README](xbot_service_interface_go/README.md). + +--- + +## Client Support Matrix + +What each client implements of the wire protocol above (`o` = only with an optional dependency, `–` = not +implemented). A missing feature is not a protocol limitation: the services accept it, the client just cannot send +or receive it yet. + +| Feature | C++ interface | Python | Go | +| -------------------------------------- | ------------- | ------ | -------- | +| Discovery (multicast) | ✓ | ✓ | ✓ | +| Claim / release | ✓ | ✓ | ✓ | +| Heartbeat + disconnect detection | ✓ | ✓ | ✓ | +| RPC call (synchronous, status → error) | ✓ | ✓ | ✓ | +| RPC fire-and-forget (without waiting) | ✓ | ✓ | ✓ | +| Inputs / outputs (`DATA`) | ✓ | ✓ | send | +| Registers (typed) | via codegen | ✓ | raw DATA | +| Transactions (atomic bundles) | ✓ | ✓ | – | +| Schema from `service.json` (validated) | via codegen | ✓ | – | +| Schema-free from the advertisement | ✓ | ✓ | – | +| Heatshrink encode / decode | encode | o | encode | +| Remote log stream (`LOG`) | service side | ✓ | – | +| Direct endpoint, no discovery | ✓ | ✓ | ✓ | +| Reconnect handling | ✓ | ✓ | – | +| Interactive shell / CLI tools | – | ✓ | – | +| Code generation needed | yes | no | no | + +--- + ## Status and Contributions xBot Framework is stable and running in production as the hardware layer for **[OpenMower](https://github.com/ClemensElflein/OpenMower)**. diff --git a/xbot_service_interface_go/README.md b/xbot_service_interface_go/README.md new file mode 100644 index 0000000..2adae8c --- /dev/null +++ b/xbot_service_interface_go/README.md @@ -0,0 +1,143 @@ +# xbot-service-interface-go + +Go client library for [xBot Framework](https://github.com/xtech/xbot_framework) services. Connects to xBot services +over UDP without code generation, for tools, bridges and tests (no cgo). + +## Installation + +```bash +go get github.com/xtech/xbot_framework/xbot_service_interface_go +``` + +Pin a release with its tag (`xbot_service_interface_go/v0.1.0`), or use the latest commit with `@main`. Requires +Go 1.23+. The only dependency is `github.com/fxamacker/cbor/v2`, which decodes the service advertisements. + +## Architecture overview + +```text +xbot.Conn — one service: discovery (or a fixed endpoint), claim, RPC calls, DATA messages + ├── Call() — synchronous RPC, returns the value or an *RPCError + ├── SendRPC() — fire-and-forget RPC (needed when someone else claimed the service) + └── SendData() — raw DATA frames: inputs and registers +xbot.Discover() — list the services that advertise on the discovery group +``` + +A service specific client is a thin wrapper around `Conn` that knows the ids from a `service.json` (function ids, +input/register ids, parameter layout). The [OpenMower](https://github.com/ClemensElflein/OpenMower) `soundctl` is +such a consumer. + +## Quick start + +```go +ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) +defer cancel() + +// 1. What is on the bus? +services, err := xbot.Discover(ctx, "") // "" = all interfaces +for _, s := range services { + fmt.Printf("%d\t%s\tv%d\t%s\n", s.ServiceID, s.Type, s.Version, s.Endpoint()) +} + +// 2. Connect to one of them (claims it unless Options.NoClaim is set). +conn, err := xbot.Dial(ctx, xbot.Options{ServiceID: 1}) +if err != nil { + log.Fatal(err) +} +defer conn.Close() + +// 3. RPC. The ids come from the service.json: function 2, parameters 0 and 1. +reply, err := conn.Call(ctx, 2, xbot.Bytes(0, []byte("hello")), xbot.Uint32(1, 3)) +switch { +case errors.Is(err, xbot.ErrBusy): + fmt.Println("service is busy, retry later") +case err != nil: + log.Fatal(err) +} +fmt.Printf("%q\n", reply) +``` + +Pass a context with a deadline, or set `Options.CallTimeout` — otherwise `DefaultCallTimeout` (2 s) applies. + +## API reference + +### `xbot.Dial(ctx, xbot.Options{...}) (*xbot.Conn, error)` + +| field | meaning | +| ------------- | ------------------------------------------------------------------------------- | +| `ServiceID` | the id to connect to (required), see `service_ids.h` | +| `BindIP` | IPv4 address of the interface to use; `""`/`0.0.0.0` = all | +| `Heartbeat` | heartbeat interval; `0` = `xbot.DefaultHeartbeat` (1 s) | +| `NoClaim` | connect without claiming: the owner keeps the responses, this client only sends | +| `Endpoint` | `"ip:port"` — skip discovery (implies `NoClaim`) | +| `CallTimeout` | default timeout for `Call` without a context deadline; `0` = 2 s | + +Discovery needs a window of a few advertisement intervals: a service advertises every second only while it is +unclaimed and younger than 60 s, afterwards (and once claimed) every 10 s. + +### `(*xbot.Conn)` + +| method | purpose | +| ------------------------------------------ | --------------------------------------------------------------------- | +| `Call(ctx, fn, params...) ([]byte, error)` | synchronous RPC: the return value, or `*RPCError`/context error | +| `SendRPC(fn, params...) error` | fire-and-forget RPC | +| `SendData(targetID, data) error` | raw DATA frame (input or register) | +| `Endpoint() string`, `ServiceID() uint16` | where the connection points to | +| `Close() error` | closes the socket; the service notices through the missing heartbeats | + +`Call` is safe for concurrent use — calls are serialised because the responses share the socket. + +### Parameters + +`xbot.Param{ID, Data}` is one parameter: the descriptor id from the `service.json` plus its bytes. Helpers: +`xbot.Bytes`, `xbot.String`, `xbot.Bool`, `xbot.Uint8`, `xbot.Uint16`, `xbot.Uint32` (little endian). Fixed size +`char[N]` parameters may be sent with or without trailing NUL bytes — the service reads the length from the +descriptor. + +### Errors + +| error | meaning | +| ----------------------- | ------------------------------------------------------------------ | +| `xbot.ErrBusy` | the service is still executing another call (`errors.Is`) | +| `xbot.ErrRPC` | the call failed inside the service | +| `xbot.ErrNotAdvertised` | the service id was not advertised; the message lists what was seen | +| `*xbot.RPCError` | carries `FunctionID` and `Status`; matches the two above via `Is` | + +### Discovery and helpers + +- `xbot.Discover(ctx, bindIP)` → `[]xbot.Advertisement` (id, type, version, endpoint), sorted by id. +- `xbot.HeatshrinkEncode(data)` → the heatshrink stream the firmware decodes (window 9, lookahead 5), e.g. for a + compressed blob register. +- `xbot.PrimaryIP()` → the first physical non-loopback IPv4 address (the claim target when binding to 0.0.0.0). + +## Scope + +The client implements what a tool like `soundctl` needs: discovery, claim, heartbeat, RPC (synchronous and +fire-and-forget), DATA sends and heatshrink encoding. It does **not** implement transactions, typed registers and +outputs, schema validation, the remote log stream or reconnect handling — the +[support matrix](../Readme.md#client-support-matrix) in the main README compares it with the C++ and Python +clients. + +## Wire compatibility + +The wire format is defined by the C++ headers of this repository, and two test files keep the two sides together: + +- `xbot/spec_test.go` parses `include/xbot/config.hpp` and `include/xbot/datatypes/XbotHeader.hpp` and fails when a + constant or a message type differs from this package. It skips when the headers are not next to the module (i.e. + when the module is used from the module cache). +- `xbot/header_test.go`, `xbot/conn_test.go` and `xbot/discovery_test.go` pin the packed sizes, the claim/DATA/RPC + frame layouts (against a fake service on the loopback interface) and the advertisement decoding. + +## Examples + +```bash +go run ./examples/discover # list the services on the network +go run ./examples/echo # RPC against examples/services/EchoService +go run ./examples/echo -addr 10.0.0.5:4242 -text hello -count 5 +``` + +## Development + +```bash +cd xbot_service_interface_go +gofmt -l . && go vet ./... && go test ./... -race +``` diff --git a/xbot_service_interface_go/examples/discover/main.go b/xbot_service_interface_go/examples/discover/main.go new file mode 100644 index 0000000..712e9a4 --- /dev/null +++ b/xbot_service_interface_go/examples/discover/main.go @@ -0,0 +1,43 @@ +// Command discover lists the services that advertise on this network. +// +// go run ./examples/discover +// go run ./examples/discover -bind 192.168.1.10 -wait 20s +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log" + "os" + "os/signal" + "time" + + "github.com/xtech/xbot_framework/xbot_service_interface_go/xbot" +) + +func main() { + bindIP := flag.String("bind", "", "local IPv4 address of the interface to listen on (default: all)") + wait := flag.Duration("wait", 12*time.Second, + "how long to listen; a service that is claimed advertises only every 10 s") + flag.Parse() + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + ctx, cancel := context.WithTimeout(ctx, *wait) + defer cancel() + + services, err := xbot.Discover(ctx, *bindIP) + if err != nil && !errors.Is(err, context.DeadlineExceeded) { + log.Fatalf("discover: %v", err) + } + if len(services) == 0 { + fmt.Printf("no service advertised within %s\n", *wait) + return + } + fmt.Printf("%-3s %-30s %-3s %s\n", "SID", "TYPE", "VER", "ENDPOINT") + for _, s := range services { + fmt.Println(s) + } +} diff --git a/xbot_service_interface_go/examples/echo/main.go b/xbot_service_interface_go/examples/echo/main.go new file mode 100644 index 0000000..6866273 --- /dev/null +++ b/xbot_service_interface_go/examples/echo/main.go @@ -0,0 +1,70 @@ +// Command echo talks to the EchoService example of this repository: it calls +// RpcEchoTest and prints the answer. +// +// Start the service first (see examples/services/EchoService), then: +// +// go run ./examples/echo +// go run ./examples/echo -addr 192.168.1.50:4242 -text hello +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "os/signal" + "strings" + "time" + + "github.com/xtech/xbot_framework/xbot_service_interface_go/xbot" +) + +// Ids from examples/services/EchoService/service.json. +const ( + echoServiceID = 1 + fnRpcEchoTest = 2 +) + +func main() { + addr := flag.String("addr", "", "service endpoint ip:port (skips discovery)") + text := flag.String("text", "hello", "text to echo (padded to char[10])") + count := flag.Uint("count", 3, "how often the service shall echo it") + wait := flag.Duration("wait", 30*time.Second, "discovery and claim budget") + flag.Parse() + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + ctx, cancel := context.WithTimeout(ctx, *wait) + defer cancel() + + opts := xbot.Options{ServiceID: echoServiceID} + if *addr != "" { + opts.Endpoint = *addr + } + conn, err := xbot.Dial(ctx, opts) + if err != nil { + log.Fatalf("dial: %v", err) + } + defer func() { _ = conn.Close() }() + fmt.Printf("connected to service %d at %s\n", conn.ServiceID(), conn.Endpoint()) + + reply, err := conn.Call(ctx, fnRpcEchoTest, + xbot.Bytes(0, fixedString(*text, 10)), // char[10] + xbot.Uint32(1, uint32(*count)), // uint32_t + ) + if err != nil { + log.Fatalf("RpcEchoTest: %v", err) + } + fmt.Printf("RpcEchoTest -> %q\n", fixedStringReply(reply)) +} + +// fixedString pads s with NUL to the size of a char[N] parameter. +func fixedString(s string, size int) []byte { + b := make([]byte, size) + copy(b, s) + return b +} + +// fixedStringReply turns a char[N] return value back into a string. +func fixedStringReply(b []byte) string { return strings.TrimRight(string(b), "\x00") } diff --git a/xbot_service_interface_go/go.mod b/xbot_service_interface_go/go.mod new file mode 100644 index 0000000..1b13ce1 --- /dev/null +++ b/xbot_service_interface_go/go.mod @@ -0,0 +1,7 @@ +module github.com/xtech/xbot_framework/xbot_service_interface_go + +go 1.23 + +require github.com/fxamacker/cbor/v2 v2.9.3 + +require github.com/x448/float16 v0.8.4 // indirect diff --git a/xbot_service_interface_go/go.sum b/xbot_service_interface_go/go.sum new file mode 100644 index 0000000..ba6042a --- /dev/null +++ b/xbot_service_interface_go/go.sum @@ -0,0 +1,4 @@ +github.com/fxamacker/cbor/v2 v2.9.3 h1:oQBnFATpNdY8gJHTndDDv5Xl4QqNaz51G5LLEPhng3Q= +github.com/fxamacker/cbor/v2 v2.9.3/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= diff --git a/xbot_service_interface_go/xbot/conn.go b/xbot_service_interface_go/xbot/conn.go new file mode 100644 index 0000000..597848e --- /dev/null +++ b/xbot_service_interface_go/xbot/conn.go @@ -0,0 +1,332 @@ +package xbot + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "net" + "strconv" + "sync" + "sync/atomic" + "time" +) + +// DefaultCallTimeout bounds a Conn.Call whose context carries no deadline. +const DefaultCallTimeout = 2 * time.Second + +// Param is one RPC parameter: the descriptor id from the service json plus the +// serialized value. The wire layout is one DataDescriptor followed by the raw +// bytes of that value, in the order the parameters are given. +type Param struct { + ID uint16 + Data []byte +} + +// Bytes returns a parameter with raw bytes (also used for blobs, arrays and +// fixed size char arrays that the caller padded itself). +func Bytes(id uint16, b []byte) Param { return Param{ID: id, Data: b} } + +// String returns a parameter holding the bytes of s, without a terminator: the +// service reads the length from the descriptor. Fixed size char arrays are +// accepted with or without trailing zero bytes (see the service json). +func String(id uint16, s string) Param { return Param{ID: id, Data: []byte(s)} } + +// Bool returns a parameter as the uint8 the service json uses for flags. +func Bool(id uint16, v bool) Param { + if v { + return Param{ID: id, Data: []byte{1}} + } + return Param{ID: id, Data: []byte{0}} +} + +// Uint8 returns an 8 bit parameter. +func Uint8(id uint16, v uint8) Param { return Param{ID: id, Data: []byte{v}} } + +// Uint16 returns a little endian 16 bit parameter. +func Uint16(id uint16, v uint16) Param { + b := make([]byte, 2) + binary.LittleEndian.PutUint16(b, v) + return Param{ID: id, Data: b} +} + +// Uint32 returns a little endian 32 bit parameter. +func Uint32(id uint16, v uint32) Param { + b := make([]byte, 4) + binary.LittleEndian.PutUint32(b, v) + return Param{ID: id, Data: b} +} + +// Options configure Dial. +type Options struct { + // ServiceID is the id to connect to (services/service_ids.h). Required. + ServiceID uint16 + // BindIP restricts the socket and the discovery listener to the interface + // that owns this IPv4 address. "" or "0.0.0.0" means all interfaces. + BindIP string + // Heartbeat is how often the service expects a heartbeat while the client + // keeps the service alive. 0 means DefaultHeartbeat. + Heartbeat time.Duration + // NoClaim connects without claiming. Use it for a service that is owned by + // someone else (on a robot: the high level system) and only receives data + // and RPCs from this client: claiming would restart the service and + // re-target its responses. + NoClaim bool + // Endpoint ("ip:port") skips discovery and connects straight to a known + // service. Implies NoClaim. + Endpoint string + // CallTimeout bounds Call calls whose context has no deadline. 0 means + // DefaultCallTimeout. + CallTimeout time.Duration +} + +// Conn is a unicast connection to one service. Calls and sends are safe for +// concurrent use; a Call serialises with other calls because the responses +// arrive on the shared socket. +type Conn struct { + udp *net.UDPConn + svcIP string + svcPort int + serviceID uint16 + heartbeatMicros uint32 + callTimeout time.Duration + + callMu sync.Mutex + seq atomic.Uint32 + callID atomic.Uint32 +} + +// Dial discovers the service (or uses opts.Endpoint) and claims it, unless +// opts.NoClaim is set. The context bounds the discovery wait and the claim +// handshake; a service that is unclaimed and older than DiscoveryTimeout only +// advertises every AdvertisementInterval, so allow a few 10 s here. +func Dial(ctx context.Context, opts Options) (*Conn, error) { + if opts.ServiceID == 0 { + return nil, errors.New("xbot: Options.ServiceID is required") + } + heartbeat := opts.Heartbeat + if heartbeat <= 0 { + heartbeat = DefaultHeartbeat + } + callTimeout := opts.CallTimeout + if callTimeout <= 0 { + callTimeout = DefaultCallTimeout + } + + udp, err := listenUDP(opts.BindIP) + if err != nil { + return nil, err + } + c := &Conn{ + udp: udp, + serviceID: opts.ServiceID, + heartbeatMicros: uint32(heartbeat / time.Microsecond), + callTimeout: callTimeout, + } + + if opts.Endpoint != "" { + host, portStr, err := net.SplitHostPort(opts.Endpoint) + if err != nil { + _ = udp.Close() + return nil, fmt.Errorf("xbot: Options.Endpoint %q: %w", opts.Endpoint, err) + } + port, err := strconv.Atoi(portStr) + if err != nil { + _ = udp.Close() + return nil, fmt.Errorf("xbot: Options.Endpoint %q: %w", opts.Endpoint, err) + } + if net.ParseIP(host) == nil { + _ = udp.Close() + return nil, fmt.Errorf("xbot: Options.Endpoint %q: not an IPv4 address", opts.Endpoint) + } + c.svcIP, c.svcPort = host, port + return c, nil + } + + adv, err := find(ctx, opts.BindIP, opts.ServiceID) + if err != nil { + _ = udp.Close() + return nil, err + } + c.svcIP, c.svcPort = adv.IP, adv.Port + + if !opts.NoClaim { + localIP := opts.BindIP + if localIP == "" || localIP == "0.0.0.0" { + localIP = PrimaryIP() + } + if err := c.claim(ctx, localIP); err != nil { + _ = udp.Close() + return nil, err + } + } + return c, nil +} + +// listenUDP opens a UDP socket bound to bindIP ("" or "0.0.0.0" = any interface). +func listenUDP(bindIP string) (*net.UDPConn, error) { + udp, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.ParseIP(bindIP)}) + if err != nil { + return nil, fmt.Errorf("xbot: udp bind %q: %w", bindIP, err) + } + return udp, nil +} + +// Close closes the socket. The service notices that the heartbeats stop. +func (c *Conn) Close() error { return c.udp.Close() } + +// Endpoint returns the unicast address of the service ("ip:port"). +func (c *Conn) Endpoint() string { + return net.JoinHostPort(c.svcIP, strconv.Itoa(c.svcPort)) +} + +// ServiceID returns the id of the connected service. +func (c *Conn) ServiceID() uint16 { return c.serviceID } + +// claim sends CLAIM and waits for the acknowledgement, retrying until the +// service answers or ctx is done. +func (c *Conn) claim(ctx context.Context, localIP string) error { + ip := net.ParseIP(localIP).To4() + if ip == nil { + return fmt.Errorf("xbot: local address %q is not an IPv4 address", localIP) + } + localPort := c.udp.LocalAddr().(*net.UDPAddr).Port + payload := PackClaimPayload(binary.BigEndian.Uint32(ip), uint16(localPort), c.heartbeatMicros) + + for { + if err := ctx.Err(); err != nil { + return fmt.Errorf("xbot: claim service %d: %w", c.serviceID, err) + } + if err := c.send(MsgClaim, 0, 0, payload); err != nil { + return err + } + hdr, _, err := c.readPacket(time.Second) + if err == nil && hdr.MessageType == MsgClaim && hdr.Arg1 == 1 { + return nil + } + if err != nil && !isTimeout(err) { + return err + } + } +} + +// Call sends an RPC call and waits for its response. It returns the raw return +// value (nil for a void function) or an *RPCError when the service answered +// busy/error. Without a deadline in ctx, Options.CallTimeout applies. +func (c *Conn) Call(ctx context.Context, functionID uint8, params ...Param) ([]byte, error) { + timeout := c.callTimeout + if deadline, ok := ctx.Deadline(); ok { + timeout = time.Until(deadline) + } + + c.callMu.Lock() + defer c.callMu.Unlock() + + callID, err := c.startCall(functionID, params) + if err != nil { + return nil, err + } + + deadline := time.Now().Add(timeout) + for { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("xbot: rpc function %d: %w", functionID, err) + } + hdr, payload, err := c.readPacket(time.Until(deadline)) + if err != nil { + if isTimeout(err) { + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, fmt.Errorf("xbot: rpc function %d: %w", functionID, ctxErr) + } + return nil, fmt.Errorf("xbot: rpc function %d: no response within %s: %w", + functionID, timeout, context.DeadlineExceeded) + } + return nil, err + } + if hdr.MessageType == MsgRPCResponse && hdr.Arg2 == callID { + if hdr.Arg1 != RPCStatusSuccess { + return nil, &RPCError{FunctionID: functionID, Status: hdr.Arg1} + } + return payload, nil + } + // Heartbeats and other unrelated packets are ignored. + } +} + +// SendRPC sends a call without waiting for the response ("fire and forget"). +// +// Needed when another client owns (claimed) the service: the firmware still +// executes the call, only the response goes to the owner. +func (c *Conn) SendRPC(functionID uint8, params ...Param) error { + _, err := c.startCall(functionID, params) + return err +} + +// SendData sends raw bytes to an id of the service: an input, a register, or +// anything else the service json names. The caller is responsible for the +// layout (see the schema) — e.g. HeatshrinkEncode for a compressed blob +// register. +func (c *Conn) SendData(targetID uint16, data []byte) error { + return c.send(MsgData, 0, targetID, data) +} + +// buildRPCPayload serialises an RPC body: one descriptor per parameter, each +// followed by that parameter's bytes. +func buildRPCPayload(params []Param) []byte { + var body []byte + for _, p := range params { + body = append(body, PackDescriptor(p.ID, uint32(len(p.Data)))...) + body = append(body, p.Data...) + } + return body +} + +// startCall sends an RPC and returns the call id it used. +func (c *Conn) startCall(functionID uint8, params []Param) (uint16, error) { + callID := uint16(c.callID.Add(1) & 0xFFFF) + return callID, c.send(MsgRPCCall, functionID, callID, buildRPCPayload(params)) +} + +// send writes one header+payload packet to the service. +func (c *Conn) send(msgType MessageType, arg1 uint8, arg2 uint16, payload []byte) error { + hdr := Header{ + ProtocolVersion: ProtocolVersion, + MessageType: msgType, + ServiceID: c.serviceID, + Arg1: arg1, + Arg2: arg2, + SequenceNo: uint16(c.seq.Add(1) & 0xFFFF), + Timestamp: nowMicros(), + PayloadSize: uint32(len(payload)), + } + pkt := make([]byte, 0, HeaderSize+len(payload)) + pkt = append(pkt, hdr.Marshal()...) + pkt = append(pkt, payload...) + _, err := c.udp.WriteToUDP(pkt, &net.UDPAddr{IP: net.ParseIP(c.svcIP), Port: c.svcPort}) + return err +} + +// readPacket reads one packet and splits it into header and payload. +func (c *Conn) readPacket(timeout time.Duration) (Header, []byte, error) { + _ = c.udp.SetReadDeadline(time.Now().Add(timeout)) + buf := make([]byte, MaxPacketSize) + n, _, err := c.udp.ReadFromUDP(buf) + if err != nil { + return Header{}, nil, err + } + data := buf[:n] + hdr, err := UnmarshalHeader(data) + if err != nil { + return hdr, nil, err + } + if len(data) != HeaderSize+int(hdr.PayloadSize) { + return hdr, nil, errPacketMismatch + } + return hdr, data[HeaderSize:], nil +} + +// isTimeout reports whether err is a read deadline expiry. +func isTimeout(err error) bool { + var ne net.Error + return errors.As(err, &ne) && ne.Timeout() +} diff --git a/xbot_service_interface_go/xbot/conn_test.go b/xbot_service_interface_go/xbot/conn_test.go new file mode 100644 index 0000000..17bc10e --- /dev/null +++ b/xbot_service_interface_go/xbot/conn_test.go @@ -0,0 +1,225 @@ +package xbot + +import ( + "bytes" + "context" + "errors" + "net" + "testing" + "time" +) + +// startFakeService runs a minimal xbot service on the loopback interface. respond +// returns the packet to send back (a zero Header means "answer nothing"); CLAIM +// is always acknowledged. +func startFakeService(t *testing.T, respond func(hdr Header, payload []byte) (Header, []byte)) (*net.UDPConn, string) { + t.Helper() + udp, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatalf("fake service: %v", err) + } + t.Cleanup(func() { _ = udp.Close() }) + + go func() { + buf := make([]byte, MaxPacketSize) + for { + n, addr, err := udp.ReadFromUDP(buf) + if err != nil { + return + } + hdr, err := UnmarshalHeader(buf[:n]) + if err != nil { + continue + } + payload := buf[HeaderSize:n] + if hdr.MessageType == MsgClaim { + ack := Header{ProtocolVersion: ProtocolVersion, MessageType: MsgClaim, + ServiceID: hdr.ServiceID, Arg1: 1} + _, _ = udp.WriteToUDP(ack.Marshal(), addr) + continue + } + outHdr, outPayload := respond(hdr, payload) + if outHdr.MessageType == MsgUnknown { + continue + } + // Fill what a test handler does not care about. + outHdr.ProtocolVersion = ProtocolVersion + if outHdr.ServiceID == 0 { + outHdr.ServiceID = hdr.ServiceID + } + outHdr.PayloadSize = uint32(len(outPayload)) + pkt := append(outHdr.Marshal(), outPayload...) + _, _ = udp.WriteToUDP(pkt, addr) + } + }() + return udp, udp.LocalAddr().String() +} + +// respondWith answers an RPC with the given status and return value. +func respondWith(status uint8, reply []byte) func(Header, []byte) (Header, []byte) { + return func(hdr Header, _ []byte) (Header, []byte) { + if hdr.MessageType != MsgRPCCall { + return Header{}, nil + } + return Header{ProtocolVersion: ProtocolVersion, MessageType: MsgRPCResponse, + ServiceID: hdr.ServiceID, Arg1: status, Arg2: hdr.Arg2}, reply + } +} + +func dialFake(t *testing.T, endpoint string) *Conn { + t.Helper() + conn, err := Dial(context.Background(), Options{ServiceID: 1, Endpoint: endpoint, CallTimeout: time.Second}) + if err != nil { + t.Fatalf("Dial: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + return conn +} + +func TestDialRequiresServiceID(t *testing.T) { + if _, err := Dial(context.Background(), Options{}); err == nil { + t.Fatal("expected an error without Options.ServiceID") + } +} + +func TestDialRejectsBadEndpoint(t *testing.T) { + for _, ep := range []string{"127.0.0.1", "localhost:4242", "127.0.0.1:http"} { + if _, err := Dial(context.Background(), Options{ServiceID: 1, Endpoint: ep}); err == nil { + t.Errorf("Endpoint %q accepted, want rejection", ep) + } + } +} + +func TestCallReturnsPayload(t *testing.T) { + _, ep := startFakeService(t, respondWith(RPCStatusSuccess, []byte("pong"))) + conn := dialFake(t, ep) + + reply, err := conn.Call(context.Background(), 2, String(0, "ping")) + if err != nil { + t.Fatalf("Call: %v", err) + } + if !bytes.Equal(reply, []byte("pong")) { + t.Fatalf("reply = %q, want %q", reply, "pong") + } +} + +// TestCallMapsStatus pins the error contract: a busy answer matches ErrBusy, an +// error answer matches ErrRPC, and both carry the function id. +func TestCallMapsStatus(t *testing.T) { + for _, c := range []struct { + name string + status uint8 + wantErr error + }{ + {"busy", RPCStatusBusy, ErrBusy}, + {"error", RPCStatusError, ErrRPC}, + } { + t.Run(c.name, func(t *testing.T) { + _, ep := startFakeService(t, respondWith(c.status, nil)) + conn := dialFake(t, ep) + + _, err := conn.Call(context.Background(), 3) + if !errors.Is(err, c.wantErr) { + t.Fatalf("err = %v, want errors.Is(…, %v)", err, c.wantErr) + } + var rpcErr *RPCError + if !errors.As(err, &rpcErr) { + t.Fatalf("err = %v, want an *RPCError", err) + } + if rpcErr.FunctionID != 3 || rpcErr.Status != c.status { + t.Fatalf("RPCError = %+v, want function 3 / status %d", rpcErr, c.status) + } + }) + } +} + +func TestCallHonoursContextDeadline(t *testing.T) { + // A service that only sends heartbeats never answers the call. + _, ep := startFakeService(t, func(hdr Header, _ []byte) (Header, []byte) { + return Header{ProtocolVersion: ProtocolVersion, MessageType: MsgHeartbeat, ServiceID: hdr.ServiceID}, nil + }) + conn := dialFake(t, ep) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + start := time.Now() + _, err := conn.Call(ctx, 1) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("err = %v, want context.DeadlineExceeded", err) + } + if elapsed := time.Since(start); elapsed > 2*time.Second { + t.Fatalf("Call blocked for %s, expected the context deadline to end it", elapsed) + } +} + +// TestSendDataFrame checks the DATA layout: arg2 carries the target id (input or +// register id) and the payload is the raw value. +func TestSendDataFrame(t *testing.T) { + got := make(chan Header, 1) + var gotPayload []byte + _, ep := startFakeService(t, func(hdr Header, payload []byte) (Header, []byte) { + if hdr.MessageType == MsgData { + gotPayload = append([]byte(nil), payload...) + select { + case got <- hdr: + default: + } + } + return Header{}, nil + }) + conn := dialFake(t, ep) + + if err := conn.SendData(5, []byte{0xDE, 0xAD}); err != nil { + t.Fatalf("SendData: %v", err) + } + select { + case hdr := <-got: + if hdr.Arg2 != 5 { + t.Errorf("arg2 = %d, want the target id 5", hdr.Arg2) + } + if hdr.ServiceID != 1 { + t.Errorf("service id = %d, want 1", hdr.ServiceID) + } + case <-time.After(2 * time.Second): + t.Fatal("no DATA frame arrived") + } + if !bytes.Equal(gotPayload, []byte{0xDE, 0xAD}) { + t.Errorf("payload = %v, want [222 173]", gotPayload) + } +} + +// TestClaimHandshake drives the CLAIM exchange directly (Dial with an Endpoint +// skips it on purpose, and real discovery needs multicast). +func TestClaimHandshake(t *testing.T) { + _, ep := startFakeService(t, respondWith(RPCStatusSuccess, nil)) + conn := dialFake(t, ep) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := conn.claim(ctx, "127.0.0.1"); err != nil { + t.Fatalf("claim: %v", err) + } +} + +func TestRPCPayloadLayout(t *testing.T) { + body := buildRPCPayload([]Param{String(0, "abc"), Uint32(1, 0x01020304)}) + want := []byte{ + 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 'a', 'b', 'c', + 0x01, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x03, 0x02, 0x01, + } + if !bytes.Equal(body, want) { + t.Fatalf("payload =\n% x\nwant\n% x", body, want) + } +} + +func TestParamConstructors(t *testing.T) { + if p := Bool(1, true); p.ID != 1 || !bytes.Equal(p.Data, []byte{1}) { + t.Errorf("Bool(true) = %+v", p) + } + if p := Bool(1, false); !bytes.Equal(p.Data, []byte{0}) { + t.Errorf("Bool(false) = %+v", p) + } + if p := Uint16(2, 0x0102); !bytes.Equal(p.Data, []byte{0x02, 0x01}) { + t.Errorf("Uint16 = %v, want little endian", p.Data) + } +} diff --git a/xbot_service_interface_go/xbot/discovery.go b/xbot_service_interface_go/xbot/discovery.go new file mode 100644 index 0000000..9a51d93 --- /dev/null +++ b/xbot_service_interface_go/xbot/discovery.go @@ -0,0 +1,259 @@ +package xbot + +import ( + "context" + "fmt" + "net" + "sort" + "strconv" + "strings" + + "github.com/fxamacker/cbor/v2" +) + +// Advertisement is one service as announced on the discovery multicast group. +type Advertisement struct { + // ServiceID is the id the service is addressed with (services/service_ids.h). + ServiceID uint16 + // Type is the service type, e.g. "FileService". + Type string + // Version is the version of the service definition the service implements. + Version int + // IP and Port are the unicast endpoint the service listens on. + IP string + Port int +} + +// Endpoint renders the unicast address as "ip:port". +func (a Advertisement) Endpoint() string { return net.JoinHostPort(a.IP, strconv.Itoa(a.Port)) } + +// String renders one line of a service table. +func (a Advertisement) String() string { + return fmt.Sprintf("%-3d %-30s %-3d %s", a.ServiceID, a.Type, a.Version, a.Endpoint()) +} + +// wireAdvertisement is the CBOR payload of a SERVICE_ADVERTISEMENT +// (ServiceInfo::to_json on the service side). +type wireAdvertisement struct { + SID int `cbor:"sid"` + Endpoint struct { + IP string `cbor:"ip"` + Port int `cbor:"port"` + } `cbor:"endpoint"` + Desc struct { + Type string `cbor:"type"` + Version int `cbor:"version"` + } `cbor:"desc"` +} + +// decodeAdvertisement decodes one discovery packet. It reports false for +// anything unusable — another message type, a payload that does not match the +// header, undecodable CBOR or a missing endpoint — which is what the framework +// does as well. +func decodeAdvertisement(data []byte) (Advertisement, bool) { + hdr, err := UnmarshalHeader(data) + if err != nil || hdr.MessageType != MsgServiceAdvertisement { + return Advertisement{}, false + } + if len(data) != HeaderSize+int(hdr.PayloadSize) { + return Advertisement{}, false + } + var adv wireAdvertisement + if err := cbor.Unmarshal(data[HeaderSize:], &adv); err != nil { + return Advertisement{}, false + } + if adv.SID <= 0 || adv.SID > 0xFFFF || adv.Endpoint.IP == "" || adv.Endpoint.Port == 0 { + return Advertisement{}, false + } + return Advertisement{ + ServiceID: uint16(adv.SID), + Type: adv.Desc.Type, + Version: adv.Desc.Version, + IP: adv.Endpoint.IP, + Port: adv.Endpoint.Port, + }, true +} + +// listen joins the discovery group and hands every usable advertisement to +// visit until visit returns true or ctx is done. It returns everything it saw in +// arrival order, so a timed-out caller can report what was there. +func listen(ctx context.Context, bindIP string, visit func(Advertisement) bool) ([]Advertisement, error) { + maddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(SDMulticastAddress, strconv.Itoa(MulticastPort))) + if err != nil { + return nil, err + } + var ifi *net.Interface + if bindIP != "" && bindIP != "0.0.0.0" { + ifi = interfaceByIP(bindIP) + } + conn, err := net.ListenMulticastUDP("udp4", ifi, maddr) + if err != nil { + return nil, fmt.Errorf("xbot: multicast listen on %s: %w", maddr, err) + } + defer conn.Close() + + // Close the socket when the context is cancelled - a blocked ReadFromUDP + // would otherwise outlive the caller. + done := make(chan struct{}) + defer close(done) + go func() { + select { + case <-ctx.Done(): + _ = conn.Close() + case <-done: + } + }() + + var seen []Advertisement + buf := make([]byte, MaxPacketSize) + for { + n, _, err := conn.ReadFromUDP(buf) + if err != nil { + if ctx.Err() != nil { + return seen, ctx.Err() + } + if ne, ok := err.(net.Error); ok && ne.Timeout() { + continue + } + return seen, err + } + adv, ok := decodeAdvertisement(buf[:n]) + if !ok { + continue + } + seen = append(seen, adv) + if visit != nil && visit(adv) { + return seen, nil + } + } +} + +// Discover lists the services advertised on the discovery group until ctx is +// done: one entry per service id, sorted by id. A window of a few advertisement +// intervals is needed to see every service — AdvertisementIntervalFast (1 s) +// only applies while a service is unclaimed and younger than DiscoveryTimeout, +// afterwards it advertises every AdvertisementInterval (10 s). +func Discover(ctx context.Context, bindIP string) ([]Advertisement, error) { + seen, err := listen(ctx, bindIP, nil) + if err != nil && len(seen) == 0 { + return nil, err + } + byID := make(map[uint16]Advertisement, len(seen)) + for _, adv := range seen { + byID[adv.ServiceID] = adv + } + out := make([]Advertisement, 0, len(byID)) + for _, adv := range byID { + out = append(out, adv) + } + sort.Slice(out, func(i, j int) bool { return out[i].ServiceID < out[j].ServiceID }) + return out, nil +} + +// find waits until the service with the given id is advertised and returns it. +// On timeout the error wraps ErrNotAdvertised and lists the ids that were seen: +// that distinguishes "nothing on this interface" from "this firmware has no such +// service". +func find(ctx context.Context, bindIP string, serviceID uint16) (Advertisement, error) { + var found *Advertisement + seen, err := listen(ctx, bindIP, func(adv Advertisement) bool { + if adv.ServiceID != serviceID { + return false + } + found = &adv + return true + }) + if found != nil { + return *found, nil + } + if err == nil { + // Every way out of listen() that is not a hit carries an error; guard + // anyway so the message below stays meaningful. + err = context.DeadlineExceeded + } + return Advertisement{}, fmt.Errorf("%w: service %d (advertised: %s): %w", + ErrNotAdvertised, serviceID, summarizeSIDs(seen), err) +} + +// summarizeSIDs lists the distinct service ids of the seen advertisements for +// error messages ("none" when the bus was silent). +func summarizeSIDs(seen []Advertisement) string { + ids := make(map[uint16]bool, len(seen)) + for _, adv := range seen { + ids[adv.ServiceID] = true + } + if len(ids) == 0 { + return "none" + } + sorted := make([]int, 0, len(ids)) + for id := range ids { + sorted = append(sorted, int(id)) + } + sort.Ints(sorted) + parts := make([]string, len(sorted)) + for i, id := range sorted { + parts[i] = strconv.Itoa(id) + } + return strings.Join(parts, ",") +} + +// interfaceByIP returns the interface that owns the given IPv4 address, or nil +// (= all interfaces) when the address is not local. +func interfaceByIP(ipStr string) *net.Interface { + ip := net.ParseIP(ipStr) + if ip == nil { + return nil + } + ifaces, err := net.Interfaces() + if err != nil { + return nil + } + for i := range ifaces { + addrs, err := ifaces[i].Addrs() + if err != nil { + continue + } + for _, a := range addrs { + if ipn, ok := a.(*net.IPNet); ok && ipn.IP.Equal(ip) { + return &ifaces[i] + } + } + } + return nil +} + +// PrimaryIP returns the first non-loopback IPv4 address of a physical +// interface, skipping docker/veth/tunnel devices. A service needs it as the +// target for heartbeats and RPC responses when the client binds to 0.0.0.0. +func PrimaryIP() string { + ifaces, err := net.Interfaces() + if err != nil { + return "127.0.0.1" + } + skip := []string{"docker", "veth", "virbr", "br-", "wg", "tun", "tap", "lo"} + for i := range ifaces { + name := ifaces[i].Name + skipped := false + for _, p := range skip { + if strings.HasPrefix(name, p) { + skipped = true + break + } + } + if skipped { + continue + } + addrs, err := ifaces[i].Addrs() + if err != nil { + continue + } + for _, a := range addrs { + if ipn, ok := a.(*net.IPNet); ok { + if ip4 := ipn.IP.To4(); ip4 != nil && !ip4.IsLoopback() { + return ip4.String() + } + } + } + } + return "127.0.0.1" +} diff --git a/xbot_service_interface_go/xbot/discovery_test.go b/xbot_service_interface_go/xbot/discovery_test.go new file mode 100644 index 0000000..d0f4433 --- /dev/null +++ b/xbot_service_interface_go/xbot/discovery_test.go @@ -0,0 +1,102 @@ +package xbot + +import ( + "strings" + "testing" + + "github.com/fxamacker/cbor/v2" +) + +// packAdvertisement builds a SERVICE_ADVERTISEMENT exactly the way the framework +// serialises ServiceInfo (sid / endpoint / desc). +func packAdvertisement(t *testing.T, sid int, ip string, port int, typ string, version int) []byte { + t.Helper() + payload, err := cbor.Marshal(map[string]any{ + "sid": sid, + "endpoint": map[string]any{"ip": ip, "port": port}, + "desc": map[string]any{"type": typ, "version": version}, + }) + if err != nil { + t.Fatal(err) + } + hdr := Header{ + ProtocolVersion: ProtocolVersion, + MessageType: MsgServiceAdvertisement, + ServiceID: uint16(sid), + PayloadSize: uint32(len(payload)), + } + return append(hdr.Marshal(), payload...) +} + +func TestDecodeAdvertisement(t *testing.T) { + adv, ok := decodeAdvertisement(packAdvertisement(t, 1, "172.16.78.150", 49153, "EchoService", 3)) + if !ok { + t.Fatal("advertisement rejected") + } + if adv.ServiceID != 1 || adv.Type != "EchoService" || adv.Version != 3 { + t.Errorf("decoded %+v, want SID 1 / EchoService / v3", adv) + } + if got, want := adv.Endpoint(), "172.16.78.150:49153"; got != want { + t.Errorf("Endpoint() = %q, want %q", got, want) + } + if got := adv.String(); !strings.HasPrefix(got, "1 EchoService") { + t.Errorf("String() = %q, want it to start with the id and type", got) + } + if !strings.Contains(adv.String(), "172.16.78.150:49153") { + t.Errorf("String() = %q, want it to contain the endpoint", adv.String()) + } +} + +// TestDecodeAdvertisementRejects pins that packets the framework would ignore +// are ignored here as well, instead of producing a bogus service entry. +func TestDecodeAdvertisementRejects(t *testing.T) { + valid := packAdvertisement(t, 1, "10.0.0.2", 4242, "EchoService", 1) + + cases := map[string][]byte{ + "empty": nil, + "header only": valid[:HeaderSize], + "truncated header": valid[:HeaderSize-1], + } + // Wrong message type. + wrongType := append([]byte(nil), valid...) + wrongType[1] = byte(MsgRPCCall) + cases["wrong message type"] = wrongType + // Payload size that does not match the packet. + shortPayload := append([]byte(nil), valid...) + shortPayload[20] = byte(int(shortPayload[20]) + 1) + cases["payload size mismatch"] = shortPayload + // Undecodable CBOR. + badCBOR := append(append([]byte(nil), valid[:HeaderSize]...), 0xFF, 0xFF, 0xFF) + cases["broken cbor"] = badCBOR + // Valid CBOR but no endpoint. + noEndpoint, err := cbor.Marshal(map[string]any{"sid": 3, "desc": map[string]any{"type": "X", "version": 1}}) + if err != nil { + t.Fatal(err) + } + hdr := Header{ProtocolVersion: ProtocolVersion, MessageType: MsgServiceAdvertisement, PayloadSize: uint32(len(noEndpoint))} + cases["no endpoint"] = append(hdr.Marshal(), noEndpoint...) + + for name, data := range cases { + if adv, ok := decodeAdvertisement(data); ok { + t.Errorf("%s: decoded %+v, want rejection", name, adv) + } + } +} + +func TestSummarizeSIDs(t *testing.T) { + for _, c := range []struct { + name string + seen []Advertisement + want string + }{ + {"nothing", nil, "none"}, + {"one", []Advertisement{{ServiceID: 7}}, "7"}, + {"sorted and deduplicated", []Advertisement{ + {ServiceID: 13}, {ServiceID: 7}, {ServiceID: 13}, + }, "7,13"}, + } { + if got := summarizeSIDs(c.seen); got != c.want { + t.Errorf("%s: summarizeSIDs() = %q, want %q", c.name, got, c.want) + } + } +} diff --git a/xbot_service_interface_go/xbot/doc.go b/xbot_service_interface_go/xbot/doc.go new file mode 100644 index 0000000..6f52d5c --- /dev/null +++ b/xbot_service_interface_go/xbot/doc.go @@ -0,0 +1,24 @@ +// Package xbot is a Go client for the xBot Framework service protocol: it +// discovers services over the UDP multicast group, claims them, and talks to +// them with RPC calls and DATA messages. +// +// The wire format is defined by the C++ headers of this repository +// (include/xbot/datatypes/XbotHeader.hpp, include/xbot/config.hpp); the +// constants in this package are checked against them by spec_test.go. +// +// Scope: discovery, claim, heartbeat, synchronous and fire-and-forget RPC, +// sending DATA (inputs and registers as raw bytes) and heatshrink encoding of +// blob registers. A service specific client is a thin wrapper around Conn that +// knows the function and register ids from a service.json — see the README for +// the support matrix of this client compared to the C++ and Python ones. +// +// Typical use: +// +// conn, err := xbot.Dial(ctx, xbot.Options{ServiceID: 13}) +// if err != nil { +// return err +// } +// defer conn.Close() +// +// reply, err := conn.Call(ctx, 3, xbot.String(0, "zz_ping.mp3"), xbot.Bool(1, true)) +package xbot diff --git a/xbot_service_interface_go/xbot/errors.go b/xbot_service_interface_go/xbot/errors.go new file mode 100644 index 0000000..59add3e --- /dev/null +++ b/xbot_service_interface_go/xbot/errors.go @@ -0,0 +1,51 @@ +package xbot + +import ( + "errors" + "fmt" +) + +// Errors returned by this package. RPC failures are reported as an *RPCError, +// which matches ErrBusy or ErrRPC with errors.Is. +var ( + // ErrBusy means the service answered "busy": it is still executing another + // call. Retrying later is reasonable. + ErrBusy = errors.New("xbot: service busy") + // ErrRPC means the service answered "error": the call itself failed (wrong + // parameters, internal error). Retrying will not help. + ErrRPC = errors.New("xbot: rpc failed") + // ErrNotAdvertised means the service was not found on the discovery group. + // The error message lists the service ids that *were* advertised. + ErrNotAdvertised = errors.New("xbot: service not advertised") +) + +// RPCError is returned by Conn.Call when the service answered the call with a +// status other than RPCStatusSuccess. +type RPCError struct { + FunctionID uint8 + Status uint8 +} + +// Error implements error. +func (e *RPCError) Error() string { + reason := "error" + if e.Status == RPCStatusBusy { + reason = "busy" + } + return fmt.Sprintf("xbot: rpc function %d answered %s (status %d)", e.FunctionID, reason, e.Status) +} + +// Is maps the status onto ErrBusy and ErrRPC, so callers do not have to look at +// the numeric status: +// +// if errors.Is(err, xbot.ErrBusy) { retry() } +func (e *RPCError) Is(target error) bool { + switch target { + case ErrBusy: + return e.Status == RPCStatusBusy + case ErrRPC: + return e.Status != RPCStatusBusy + default: + return false + } +} diff --git a/xbot_service_interface_go/xbot/header.go b/xbot_service_interface_go/xbot/header.go new file mode 100644 index 0000000..c5762dc --- /dev/null +++ b/xbot_service_interface_go/xbot/header.go @@ -0,0 +1,200 @@ +package xbot + +import ( + "encoding/binary" + "errors" + "fmt" + "time" +) + +// MessageType is the 8 bit message type of an XbotHeader +// (see include/xbot/datatypes/XbotHeader.hpp). +// +// The first bit of the value tells how the payload is encoded: 0 = raw binary +// (DATA, RPC, CLAIM, ...), 1 = CBOR (service discovery). +type MessageType uint8 + +// Message types. Not all of them are used by this client yet — the list +// documents the wire protocol. +const ( + MsgUnknown MessageType = 0x00 + MsgData MessageType = 0x01 + MsgConfigurationRequest MessageType = 0x02 + MsgClaim MessageType = 0x03 + MsgHeartbeat MessageType = 0x04 + MsgTransaction MessageType = 0x05 + MsgRPCCall MessageType = 0x06 + MsgRPCResponse MessageType = 0x07 + MsgLog MessageType = 0x7F + MsgServiceAdvertisement MessageType = 0x80 + MsgServiceQuery MessageType = 0x81 +) + +// String returns the symbol of the message type, e.g. "RPC_CALL". +func (m MessageType) String() string { + switch m { + case MsgData: + return "DATA" + case MsgConfigurationRequest: + return "CONFIGURATION_REQUEST" + case MsgClaim: + return "CLAIM" + case MsgHeartbeat: + return "HEARTBEAT" + case MsgTransaction: + return "TRANSACTION" + case MsgRPCCall: + return "RPC_CALL" + case MsgRPCResponse: + return "RPC_RESPONSE" + case MsgLog: + return "LOG" + case MsgServiceAdvertisement: + return "SERVICE_ADVERTISEMENT" + case MsgServiceQuery: + return "SERVICE_QUERY" + case MsgUnknown: + return "UNKNOWN" + default: + return fmt.Sprintf("MessageType(0x%02x)", uint8(m)) + } +} + +// RPC status codes (arg1 of an RPC_RESPONSE). +const ( + RPCStatusSuccess uint8 = 0 + RPCStatusBusy uint8 = 1 + RPCStatusError uint8 = 2 +) + +// Protocol constants, mirroring include/xbot/config.hpp. spec_test.go fails +// when the C++ side and this list drift apart. +const ( + // ProtocolVersion is the version this client speaks. + ProtocolVersion uint8 = 1 + // HeaderSize is the size of the packed XbotHeader. + HeaderSize = 24 + // DescriptorSize is the size of one DataDescriptor (target id + payload size). + DescriptorSize = 8 + // ClaimSize is the size of a ClaimPayload. + ClaimSize = 10 + // MaxPacketSize is the largest UDP payload a peer may send, sized so that a + // full packet fits into one unfragmented Ethernet frame. + MaxPacketSize = 1472 + + // MulticastPort is used for discovery and remote logging. + MulticastPort = 4242 + // SDMulticastAddress is the IPv4 group services advertise on. + SDMulticastAddress = "233.255.255.0" + // LogMulticastAddress carries the remote log stream. + LogMulticastAddress = "233.255.255.1" + + // DefaultHeartbeat is the heartbeat interval a service assumes when the + // client does not ask for another one. + DefaultHeartbeat = 1 * time.Second + // HeartbeatJitter is the tolerance on top of a heartbeat before a service + // considers the client gone. + HeartbeatJitter = 100 * time.Millisecond +) + +// Discovery timing, mirroring include/xbot/config.hpp. +const ( + // AdvertisementInterval is how often a service advertises itself once it is + // claimed or has stayed unclaimed for DiscoveryTimeout. + AdvertisementInterval = 10 * time.Second + // AdvertisementIntervalFast is used while a service is unclaimed and + // younger than DiscoveryTimeout. + AdvertisementIntervalFast = 1 * time.Second + // DiscoveryTimeout is how long a service keeps the fast interval. + DiscoveryTimeout = 60 * time.Second +) + +var errPacketMismatch = errors.New("xbot: packet size does not match the header") + +// Header is the 24-byte packed xbot protocol header. +type Header struct { + ProtocolVersion uint8 + MessageType MessageType + // Flags: bit 0 = reboot (set after a service start, cleared once the + // sequence number rolled over at least once). + Flags uint8 + // Reserved1 is padding and must stay zero. + Reserved1 uint8 + ServiceID uint16 + // Arg1 is message specific: the RPC function id for RPC_CALL, the status for + // RPC_RESPONSE, the log level for LOG, the transaction kind for TRANSACTION. + Arg1 uint8 + // Reserved2 is padding and must stay zero. + Reserved2 uint8 + // Arg2 is message specific: the call id for RPC_CALL and RPC_RESPONSE, the + // target id for DATA. + Arg2 uint16 + // SequenceNo counts the messages of one sender, wraps at 16 bit. + SequenceNo uint16 + // Timestamp is the send time in microseconds since the unix epoch. + Timestamp uint64 + // PayloadSize is the length of the bytes that follow the header. + PayloadSize uint32 +} + +// Marshal packs the header into its 24 wire bytes (little endian). +func (h Header) Marshal() []byte { + b := make([]byte, HeaderSize) + b[0] = h.ProtocolVersion + b[1] = uint8(h.MessageType) + b[2] = h.Flags + b[3] = h.Reserved1 + binary.LittleEndian.PutUint16(b[4:6], h.ServiceID) + b[6] = h.Arg1 + b[7] = h.Reserved2 + binary.LittleEndian.PutUint16(b[8:10], h.Arg2) + binary.LittleEndian.PutUint16(b[10:12], h.SequenceNo) + binary.LittleEndian.PutUint64(b[12:20], h.Timestamp) + binary.LittleEndian.PutUint32(b[20:24], h.PayloadSize) + return b +} + +// UnmarshalHeader parses the 24 wire bytes of a header. +func UnmarshalHeader(data []byte) (Header, error) { + var h Header + if len(data) < HeaderSize { + return h, fmt.Errorf("xbot: header too short: %d bytes", len(data)) + } + h.ProtocolVersion = data[0] + h.MessageType = MessageType(data[1]) + h.Flags = data[2] + h.Reserved1 = data[3] + h.ServiceID = binary.LittleEndian.Uint16(data[4:6]) + h.Arg1 = data[6] + h.Reserved2 = data[7] + h.Arg2 = binary.LittleEndian.Uint16(data[8:10]) + h.SequenceNo = binary.LittleEndian.Uint16(data[10:12]) + h.Timestamp = binary.LittleEndian.Uint64(data[12:20]) + h.PayloadSize = binary.LittleEndian.Uint32(data[20:24]) + return h, nil +} + +// PackDescriptor packs an 8-byte DataDescriptor: the target id (a function +// parameter id, an input id or a register id) and the size of the data that +// follows it. +func PackDescriptor(targetID uint16, payloadSize uint32) []byte { + b := make([]byte, DescriptorSize) + binary.LittleEndian.PutUint16(b[0:2], targetID) + binary.LittleEndian.PutUint32(b[4:8], payloadSize) + return b +} + +// PackClaimPayload packs a 10-byte ClaimPayload: where the service shall send +// its responses, and how often this client sends heartbeats. +// +// targetIP is the network order (big endian) uint32 of the local address, as +// produced by binary.BigEndian.Uint32(ip.To4()). +func PackClaimPayload(targetIP uint32, targetPort uint16, heartbeatMicros uint32) []byte { + b := make([]byte, ClaimSize) + binary.LittleEndian.PutUint32(b[0:4], targetIP) + binary.LittleEndian.PutUint16(b[4:6], targetPort) + binary.LittleEndian.PutUint32(b[6:10], heartbeatMicros) + return b +} + +func nowMicros() uint64 { return uint64(time.Now().UnixMicro()) } diff --git a/xbot_service_interface_go/xbot/header_test.go b/xbot_service_interface_go/xbot/header_test.go new file mode 100644 index 0000000..080befa --- /dev/null +++ b/xbot_service_interface_go/xbot/header_test.go @@ -0,0 +1,100 @@ +package xbot + +import ( + "encoding/binary" + "net" + "testing" +) + +func TestHeaderRoundTrip(t *testing.T) { + h := Header{ + ProtocolVersion: ProtocolVersion, + MessageType: MsgRPCCall, + ServiceID: 12, + Arg1: 2, + Arg2: 42, + SequenceNo: 7, + Timestamp: 123456789, + PayloadSize: 512, + } + b := h.Marshal() + if len(b) != HeaderSize { + t.Fatalf("header size = %d, want %d", len(b), HeaderSize) + } + got, err := UnmarshalHeader(b) + if err != nil { + t.Fatal(err) + } + if got != h { + t.Fatalf("round trip mismatch:\n got %+v\nwant %+v", got, h) + } +} + +// TestWireSizes pins the packed sizes against the C++ structs and the Python +// reference (datatypes.py: header '= 2. + hsMinMatch = 2 +) + +// HeatshrinkEncode compresses data into the heatshrink (LZSS) stream format that +// the firmware's HeatshrinkDataSource (window 9, lookahead 5) decodes. +// +// Services use it for blob registers: a blob that is stored compressed is sent +// as the heatshrink stream of its bytes (see Register::compressed in the service +// json schema). +// +// The stream is a sequence of tokens, MSB-first packed: +// +// literal : 1 (tag) + 8 bits (byte) +// backref : 0 (tag) + 9 bits (offset-1) + 5 bits (length-1) +func HeatshrinkEncode(data []byte) []byte { + var out []byte + var cur byte + mask := byte(0x80) + + pushBit := func(bit uint8) { + if bit != 0 { + cur |= mask + } + mask >>= 1 + if mask == 0 { + out = append(out, cur) + cur = 0 + mask = 0x80 + } + } + pushBits := func(count int, bits uint16) { + for i := count - 1; i >= 0; i-- { + pushBit(uint8((bits >> uint(i)) & 1)) + } + } + + for pos := 0; pos < len(data); { + start := pos - hsWindowSize + if start < 0 { + start = 0 + } + maxLen := hsLookaheadSize + if pos+maxLen > len(data) { + maxLen = len(data) - pos + } + + bestLen, bestOff := 0, 0 + for i := start; i < pos; i++ { + l := 0 + for l < maxLen && data[i+l] == data[pos+l] { + l++ + } + if l > bestLen { + bestLen = l + bestOff = pos - i + if l == maxLen { + break + } + } + } + + if bestLen >= hsMinMatch { + pushBits(1, hsBackrefMarker) + pushBits(hsWindowBits, uint16(bestOff-1)) + pushBits(hsLookaheadBits, uint16(bestLen-1)) + pos += bestLen + } else { + pushBits(1, hsLiteralMarker) + pushBits(8, uint16(data[pos])) + pos++ + } + } + + if mask != 0x80 { + out = append(out, cur) // flush the partial byte, zero padded + } + return out +} diff --git a/xbot_service_interface_go/xbot/heatshrink_test.go b/xbot_service_interface_go/xbot/heatshrink_test.go new file mode 100644 index 0000000..0b9f5dc --- /dev/null +++ b/xbot_service_interface_go/xbot/heatshrink_test.go @@ -0,0 +1,102 @@ +package xbot + +import ( + "bytes" + "testing" +) + +// hsDecode mirrors the firmware's heatshrink decoder (literals + backrefs, +// MSB-first bits, window 512). It exists to verify the encoder in tests. +func hsDecode(data []byte) []byte { + var out []byte + window := make([]byte, hsWindowSize) + head := 0 + + bitPos := 0 + readBits := func(count int) (uint16, bool) { + if bitPos+count > len(data)*8 { + return 0, false + } + var v uint16 + for i := 0; i < count; i++ { + b := data[bitPos>>3] + shift := uint(7 - (bitPos & 7)) + v = (v << 1) | uint16((b>>shift)&1) + bitPos++ + } + return v, true + } + + for { + tag, ok := readBits(1) + if !ok { + break + } + if tag == hsLiteralMarker { + b, ok := readBits(8) + if !ok { + break + } + c := byte(b) + window[head&(hsWindowSize-1)] = c + head++ + out = append(out, c) + continue + } + // Backref; a trailing partial token (zero padding) is ignored. + off, ok := readBits(hsWindowBits) + if !ok { + break + } + cnt, ok := readBits(hsLookaheadBits) + if !ok { + break + } + offset := int(off) + 1 + length := int(cnt) + 1 + for i := 0; i < length; i++ { + c := window[(head-offset)&(hsWindowSize-1)] + window[head&(hsWindowSize-1)] = c + head++ + out = append(out, c) + } + } + return out +} + +func TestHeatshrinkRoundTrip(t *testing.T) { + cases := [][]byte{ + nil, + {}, + []byte("a"), + []byte("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + []byte("abcabcabcabcabcabc"), + []byte("boot_ping boot_complete boot_ping boot_complete"), + []byte(`{"sounds":{"boot_complete":{"type":"mp3","volume":100,"file":"en_hi-i-am-steve.mp3"}}}`), + } + for i, c := range cases { + enc := HeatshrinkEncode(c) + if dec := hsDecode(enc); !bytes.Equal(c, dec) { + t.Fatalf("case %d: round trip mismatch\n in: %q\nout: %q", i, c, dec) + } + } +} + +func TestHeatshrinkCompressesRepeats(t *testing.T) { + data := bytes.Repeat([]byte("the quick brown fox "), 30) + enc := HeatshrinkEncode(data) + if len(enc) >= len(data) { + t.Fatalf("expected compression: encoded %d >= raw %d", len(enc), len(data)) + } + if dec := hsDecode(enc); !bytes.Equal(dec, data) { + t.Fatal("round trip mismatch for repeated input") + } +} + +// TestHeatshrinkStreamParams pins the parameters the firmware builds with +// (HEATSHRINK_STATIC_WINDOW_BITS / _LOOKAHEAD_BITS). +func TestHeatshrinkStreamParams(t *testing.T) { + if hsWindowBits != 9 || hsLookaheadBits != 5 { + t.Fatalf("window/lookahead = %d/%d, want 9/5", hsWindowBits, hsLookaheadBits) + } +} diff --git a/xbot_service_interface_go/xbot/spec_test.go b/xbot_service_interface_go/xbot/spec_test.go new file mode 100644 index 0000000..21e990c --- /dev/null +++ b/xbot_service_interface_go/xbot/spec_test.go @@ -0,0 +1,152 @@ +package xbot + +import ( + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" + "time" +) + +// This file keeps the Go constants in sync with the C++ side. It parses the +// headers of this repository, so it only runs in a checkout: inside the module +// cache (go get) the files are not part of the module and the tests skip. + +// readSpec returns the content of a header of the C++ side. +func readSpec(t *testing.T, rel string) string { + t.Helper() + path := filepath.Join("..", "..", rel) + b, err := os.ReadFile(path) + if err != nil { + t.Skipf("%s not available (%v) - only checked in the repository", path, err) + } + return string(b) +} + +// constValue pulls " = ;" out of a C++ header and strips the digit +// separators C++ allows (10'000'000). +var constValue = regexp.MustCompile(`(?m)^[^/\n]*\b(\w+)\s*=\s*("[^"]*"|[0-9']+)\s*;`) + +func cppConstants(t *testing.T, src string) map[string]string { + t.Helper() + out := make(map[string]string) + for _, m := range constValue.FindAllStringSubmatch(src, -1) { + out[m[1]] = strings.ReplaceAll(m[2], "'", "") + } + if len(out) == 0 { + t.Fatal("no constants parsed - the header format changed") + } + return out +} + +// TestConstantsMatchConfigHpp is the drift test between include/xbot/config.hpp +// and this package. +func TestConstantsMatchConfigHpp(t *testing.T) { + c := cppConstants(t, readSpec(t, "include/xbot/config.hpp")) + + num := func(name string) int64 { + t.Helper() + raw, ok := c[name] + if !ok { + t.Fatalf("%s not found in config.hpp", name) + } + v, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + t.Fatalf("%s = %q is not a number", name, raw) + } + return v + } + str := func(name string) string { + t.Helper() + raw, ok := c[name] + if !ok { + t.Fatalf("%s not found in config.hpp", name) + } + return strings.Trim(raw, `"`) + } + micros := func(name string) time.Duration { + t.Helper() + return time.Duration(num(name)) * time.Microsecond + } + + if got := int(num("max_packet_size")); got != MaxPacketSize { + t.Errorf("MaxPacketSize = %d, config.hpp says %d", MaxPacketSize, got) + } + if got := int(num("multicast_port")); got != MulticastPort { + t.Errorf("MulticastPort = %d, config.hpp says %d", MulticastPort, got) + } + if got := str("sd_multicast_address"); got != SDMulticastAddress { + t.Errorf("SDMulticastAddress = %q, config.hpp says %q", SDMulticastAddress, got) + } + if got := str("remote_log_multicast_address"); got != LogMulticastAddress { + t.Errorf("LogMulticastAddress = %q, config.hpp says %q", LogMulticastAddress, got) + } + if got := micros("sd_advertisement_interval_micros"); got != AdvertisementInterval { + t.Errorf("AdvertisementInterval = %s, config.hpp says %s", AdvertisementInterval, got) + } + if got := micros("sd_advertisement_interval_micros_fast"); got != AdvertisementIntervalFast { + t.Errorf("AdvertisementIntervalFast = %s, config.hpp says %s", AdvertisementIntervalFast, got) + } + if got := micros("sd_discovery_timeout_micros"); got != DiscoveryTimeout { + t.Errorf("DiscoveryTimeout = %s, config.hpp says %s", DiscoveryTimeout, got) + } + if got := micros("default_heartbeat_micros"); got != DefaultHeartbeat { + t.Errorf("DefaultHeartbeat = %s, config.hpp says %s", DefaultHeartbeat, got) + } + if got := micros("heartbeat_jitter"); got != HeartbeatJitter { + t.Errorf("HeartbeatJitter = %s, config.hpp says %s", HeartbeatJitter, got) + } +} + +// TestMessageTypesMatchHeaderHpp pins the MessageType enum against +// include/xbot/datatypes/XbotHeader.hpp. +func TestMessageTypesMatchHeaderHpp(t *testing.T) { + src := readSpec(t, "include/xbot/datatypes/XbotHeader.hpp") + + enumBody := regexp.MustCompile(`(?s)enum class MessageType[^{]*\{(.*?)\}`).FindStringSubmatch(src) + if enumBody == nil { + t.Fatal("enum class MessageType not found - the header format changed") + } + entries := regexp.MustCompile(`(\w+)\s*=\s*(0x[0-9A-Fa-f]+)`).FindAllStringSubmatch(enumBody[1], -1) + if len(entries) == 0 { + t.Fatal("no enumerators parsed from XbotHeader.hpp") + } + + ours := map[string]MessageType{ + "UNKNOWN": MsgUnknown, + "DATA": MsgData, + "CONFIGURATION_REQUEST": MsgConfigurationRequest, + "CLAIM": MsgClaim, + "HEARTBEAT": MsgHeartbeat, + "TRANSACTION": MsgTransaction, + "RPC_CALL": MsgRPCCall, + "RPC_RESPONSE": MsgRPCResponse, + "LOG": MsgLog, + "SERVICE_ADVERTISEMENT": MsgServiceAdvertisement, + "SERVICE_QUERY": MsgServiceQuery, + } + seen := make(map[string]bool, len(entries)) + for _, e := range entries { + name, raw := e[1], e[2] + want, ok := ours[name] + if !ok { + t.Errorf("MessageType %s exists in the header but not in Go", name) + continue + } + seen[name] = true + v, err := strconv.ParseUint(strings.TrimPrefix(raw, "0x"), 16, 8) + if err != nil { + t.Fatalf("%s = %q is not a hex byte", name, raw) + } + if uint8(want) != uint8(v) { + t.Errorf("%s = %#x in Go, %#x in the header", name, uint8(want), v) + } + } + for name := range ours { + if !seen[name] { + t.Errorf("MessageType %s is missing in XbotHeader.hpp", name) + } + } +} From 43ac02b4562f5337f0d0f4bf2850cf9537040d0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Ebeling?= Date: Wed, 28 Oct 2026 15:53:15 +0100 Subject: [PATCH 04/11] Add releasing infos (prefixed tags) --- xbot_service_interface_go/README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/xbot_service_interface_go/README.md b/xbot_service_interface_go/README.md index 2adae8c..d68b56b 100644 --- a/xbot_service_interface_go/README.md +++ b/xbot_service_interface_go/README.md @@ -141,3 +141,22 @@ go run ./examples/echo -addr 10.0.0.5:4242 -text hello -count 5 cd xbot_service_interface_go gofmt -l . && go vet ./... && go test ./... -race ``` + +## Releasing + +This module lives in a subdirectory of a repository whose root tags belong to other packages (the C++ releases and +the Python interface). Go therefore expects **prefixed tags** — the tag must match the module directory relative to +the repository root: + +```bash +git tag -a xbot_service_interface_go/v0.1.0 -m "xbot-service-interface-go v0.1.0" +git push origin xbot_service_interface_go/v0.1.0 +``` + +- A bare `v0.1.6` (or the existing `v1.0.0`) addresses a module in the repository *root*; this package is not that + module, so those tags are ignored here. Never tag this module with an unprefixed version. +- `git tag -l 'xbot_service_interface_go/*'` lists the Go releases; + `go list -m -versions github.com/xtech/xbot_framework/xbot_service_interface_go` shows what consumers can fetch. +- Pre-releases work (`xbot_service_interface_go/v0.2.0-rc1`). A major version ≥ 2 additionally requires the module + path to end in `/v2` (`…/xbot_service_interface_go/v2`, tag `xbot_service_interface_go/v2.0.0`). +- Until the first tag exists, `go get …@main` or `@` works as well. From 2aa900b819eb459ec9092df12bcd4409656335d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Ebeling?= Date: Wed, 28 Oct 2026 16:33:11 +0100 Subject: [PATCH 05/11] feat(go): add Data type and SendTransaction (configuration transactions) --- Readme.md | 2 +- xbot_service_interface_go/README.md | 5 ++- xbot_service_interface_go/xbot/conn.go | 31 ++++++++++++++-- xbot_service_interface_go/xbot/conn_test.go | 41 +++++++++++++++++++++ 4 files changed, 72 insertions(+), 7 deletions(-) diff --git a/Readme.md b/Readme.md index bc1e3a1..b9a0b2c 100644 --- a/Readme.md +++ b/Readme.md @@ -369,7 +369,7 @@ or receive it yet. | RPC fire-and-forget (without waiting) | ✓ | ✓ | ✓ | | Inputs / outputs (`DATA`) | ✓ | ✓ | send | | Registers (typed) | via codegen | ✓ | raw DATA | -| Transactions (atomic bundles) | ✓ | ✓ | – | +| Transactions (atomic bundles) | ✓ | ✓ | send | | Schema from `service.json` (validated) | via codegen | ✓ | – | | Schema-free from the advertisement | ✓ | ✓ | – | | Heatshrink encode / decode | encode | o | encode | diff --git a/xbot_service_interface_go/README.md b/xbot_service_interface_go/README.md index d68b56b..555fa8e 100644 --- a/xbot_service_interface_go/README.md +++ b/xbot_service_interface_go/README.md @@ -81,6 +81,7 @@ unclaimed and younger than 60 s, afterwards (and once claimed) every 10 s. | `Call(ctx, fn, params...) ([]byte, error)` | synchronous RPC: the return value, or `*RPCError`/context error | | `SendRPC(fn, params...) error` | fire-and-forget RPC | | `SendData(targetID, data) error` | raw DATA frame (input or register) | +| `SendTransaction(config, frames...) error` | several DATA frames in one packet; `config` marks a reconfiguration | | `Endpoint() string`, `ServiceID() uint16` | where the connection points to | | `Close() error` | closes the socket; the service notices through the missing heartbeats | @@ -112,8 +113,8 @@ descriptor. ## Scope The client implements what a tool like `soundctl` needs: discovery, claim, heartbeat, RPC (synchronous and -fire-and-forget), DATA sends and heatshrink encoding. It does **not** implement transactions, typed registers and -outputs, schema validation, the remote log stream or reconnect handling — the +fire-and-forget), DATA and configuration transactions, and heatshrink encoding. It does **not** implement typed +registers and outputs, schema validation, the remote log stream or reconnect handling — the [support matrix](../Readme.md#client-support-matrix) in the main README compares it with the C++ and Python clients. diff --git a/xbot_service_interface_go/xbot/conn.go b/xbot_service_interface_go/xbot/conn.go index 597848e..13d6538 100644 --- a/xbot_service_interface_go/xbot/conn.go +++ b/xbot_service_interface_go/xbot/conn.go @@ -262,14 +262,37 @@ func (c *Conn) SendRPC(functionID uint8, params ...Param) error { return err } -// SendData sends raw bytes to an id of the service: an input, a register, or -// anything else the service json names. The caller is responsible for the -// layout (see the schema) — e.g. HeatshrinkEncode for a compressed blob -// register. +// Data is one DATA frame: the target id of the service (an input, a register or +// any other id the service json names) plus its raw payload. +type Data struct { + ID uint16 + Data []byte +} + +// SendData sends one DATA frame to the service. func (c *Conn) SendData(targetID uint16, data []byte) error { return c.send(MsgData, 0, targetID, data) } +// SendTransaction bundles several DATA frames into one UDP packet (a +// TRANSACTION message) — the C++ counterpart of StartTransaction/CommitTransaction. +// +// A configuration transaction (configuration = true) is how registers are +// written: the service applies them as one reconfiguration, and usually persists +// them. Plain data transactions group frequent updates (inputs, sensor data). +func (c *Conn) SendTransaction(configuration bool, frames ...Data) error { + var body []byte + for _, f := range frames { + body = append(body, PackDescriptor(f.ID, uint32(len(f.Data)))...) + body = append(body, f.Data...) + } + arg1 := uint8(0) + if configuration { + arg1 = 1 + } + return c.send(MsgTransaction, arg1, 0, body) +} + // buildRPCPayload serialises an RPC body: one descriptor per parameter, each // followed by that parameter's bytes. func buildRPCPayload(params []Param) []byte { diff --git a/xbot_service_interface_go/xbot/conn_test.go b/xbot_service_interface_go/xbot/conn_test.go index 17bc10e..5a01c88 100644 --- a/xbot_service_interface_go/xbot/conn_test.go +++ b/xbot_service_interface_go/xbot/conn_test.go @@ -223,3 +223,44 @@ func TestParamConstructors(t *testing.T) { t.Errorf("Uint16 = %v, want little endian", p.Data) } } + +// TestSendTransactionLayout pins the configuration transaction used for register +// writes: one descriptor per frame, and arg1 = 1 marks it as a reconfiguration. +func TestSendTransactionLayout(t *testing.T) { + got := make(chan Header, 1) + var gotPayload []byte + _, ep := startFakeService(t, func(hdr Header, payload []byte) (Header, []byte) { + if hdr.MessageType == MsgTransaction { + gotPayload = append([]byte(nil), payload...) + select { + case got <- hdr: + default: + } + } + return Header{}, nil + }) + conn := dialFake(t, ep) + + err := conn.SendTransaction(true, + Data{ID: 0, Data: []byte("ab")}, + Data{ID: 7, Data: []byte{0xFF}}, + ) + if err != nil { + t.Fatalf("SendTransaction: %v", err) + } + select { + case hdr := <-got: + if hdr.Arg1 != 1 { + t.Errorf("arg1 = %d, want 1 (configuration transaction)", hdr.Arg1) + } + case <-time.After(2 * time.Second): + t.Fatal("no TRANSACTION frame arrived") + } + want := []byte{ + 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 'a', 'b', + 0x07, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0xFF, + } + if !bytes.Equal(gotPayload, want) { + t.Fatalf("payload =\n% x\nwant\n% x", gotPayload, want) + } +} From ad87ebeeda4b6a46691ed6992d2b735d1a72c33d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Ebeling?= Date: Wed, 28 Oct 2026 18:48:06 +0100 Subject: [PATCH 06/11] docs(codegen): document Go service client codegen tool and usage Add documentation for the new `xbot-codegen` command across the root Readme, codegen README, and Go module README, explaining how to generate typed clients from a service.json, the `-service-id` option, CI sync workflow, and current limitations (outputs, non-scalar arrays). --- Readme.md | 3 + codegen/README.md | 23 ++ xbot_service_interface_go/README.md | 19 ++ .../cmd/xbot-codegen/main.go | 60 ++++ xbot_service_interface_go/codegen/emit.go | 190 ++++++++++++ .../codegen/generate_test.go | 94 ++++++ xbot_service_interface_go/codegen/render.go | 278 ++++++++++++++++++ xbot_service_interface_go/codegen/schema.go | 219 ++++++++++++++ .../codegen/testdata/demo_service.golden.go | 175 +++++++++++ .../codegen/testdata/demo_service.json | 107 +++++++ .../testdata/demo_service_noid.golden.go | 172 +++++++++++ 11 files changed, 1340 insertions(+) create mode 100644 xbot_service_interface_go/cmd/xbot-codegen/main.go create mode 100644 xbot_service_interface_go/codegen/emit.go create mode 100644 xbot_service_interface_go/codegen/generate_test.go create mode 100644 xbot_service_interface_go/codegen/render.go create mode 100644 xbot_service_interface_go/codegen/schema.go create mode 100644 xbot_service_interface_go/codegen/testdata/demo_service.golden.go create mode 100644 xbot_service_interface_go/codegen/testdata/demo_service.json create mode 100644 xbot_service_interface_go/codegen/testdata/demo_service_noid.golden.go diff --git a/Readme.md b/Readme.md index b9a0b2c..2d8927e 100644 --- a/Readme.md +++ b/Readme.md @@ -352,6 +352,9 @@ payloads. Heatshrink-compressed blob registers use `xbot.HeatshrinkEncode`. Install a specific version by tag (`xbot_service_interface_go/v0.1.0`) or the latest commit with `@main`. Full API reference and examples in the [package README](xbot_service_interface_go/README.md). +Typed clients for a concrete service are generated from its `service.json` with `xbot-codegen` — see +[Service clients](xbot_service_interface_go/README.md#service-clients-codegen). + --- ## Client Support Matrix diff --git a/codegen/README.md b/codegen/README.md index 437039c..eddc5b1 100644 --- a/codegen/README.md +++ b/codegen/README.md @@ -10,3 +10,26 @@ The code generator will generate: Look at the EchoService for an example. + +## Go client generator + +`xbot_service_interface_go/cmd/xbot-codegen` generates a Go client from the same `service.json` : + +```bash +go run github.com/xtech/xbot_framework/xbot_service_interface_go/cmd/xbot-codegen \ + -service services/file_service.json -service-id 12 -out client_generated.go +``` + +The generated package combines the generic transport of the Go module (`xbot.Conn`: discovery, +claim, RPC, DATA and configuration transactions) with the ids and typed calls of one service: the +service id, the function ids, the enums, one method per RPC function, a setter per register and a +sender per input. `-service-id` is optional - without it, `Dial` takes the id as an argument, which +is how the C++ interfaces get it. + +The output is `gofmt`ed and checked in by the consumer; `go generate ./... && git diff --exit-code` +in CI keeps it in sync with the schema. See `xbot_service_interface_go/codegen` for the +implementation and the golden tests. + +Not supported yet: outputs (DATA received from the service) need a receive loop in the Go client, +and arrays other than `char[n]`/`uint8_t[n]` are rejected while generating (loudly) instead of +producing broken code. diff --git a/xbot_service_interface_go/README.md b/xbot_service_interface_go/README.md index 555fa8e..3e8a90e 100644 --- a/xbot_service_interface_go/README.md +++ b/xbot_service_interface_go/README.md @@ -110,6 +110,25 @@ descriptor. compressed blob register. - `xbot.PrimaryIP()` → the first physical non-loopback IPv4 address (the claim target when binding to 0.0.0.0). +## Service clients (codegen) + +`cmd/xbot-codegen` generates a typed client for **one** service from its `service.json`: + +```bash +go run github.com/xtech/xbot_framework/xbot_service_interface_go/cmd/xbot-codegen \ + -service ../../services/file_service.json -service-id 12 -out client_generated.go +``` + +The generated file composes this package with the service definition - exactly the split the C++ +side uses: `xbot.Conn` brings discovery, claim, RPC and DATA/configuration transactions, the +generated code adds the service id, the function ids, the enums, one method per RPC function, a +setter per register and a sender per input. `-service-id` is optional: without it `Dial` takes the +id as an argument. + +The output is `gofmt`ed and checked in (Go convention); a `go:generate` directive plus +`go generate ./... && git diff --exit-code` in CI keeps it in sync with the schema. Generator and +golden tests live in `codegen/`. + ## Scope The client implements what a tool like `soundctl` needs: discovery, claim, heartbeat, RPC (synchronous and diff --git a/xbot_service_interface_go/cmd/xbot-codegen/main.go b/xbot_service_interface_go/cmd/xbot-codegen/main.go new file mode 100644 index 0000000..0d2928e --- /dev/null +++ b/xbot_service_interface_go/cmd/xbot-codegen/main.go @@ -0,0 +1,60 @@ +// Command xbot-codegen generates a Go client for an xbot service from its +// service.json. +// +// Consumers run it from a //go:generate directive: +// +// //go:generate go run github.com/xtech/xbot_framework/xbot_service_interface_go/cmd/xbot-codegen -service ../../services/file_service.json -service-id 12 -out client_generated.go +// +// The generated file is checked in, so `go build` never needs this tool; the +// schema and the generated client stay in sync through CI +// (`go generate ./... && git diff --exit-code`). +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/xtech/xbot_framework/xbot_service_interface_go/codegen" +) + +func main() { + service := flag.String("service", "", "path to the service.json (required)") + out := flag.String("out", "", "output file (default: stdout)") + serviceID := flag.Uint("service-id", 0, "service id from service_ids.h (0: Dial takes the id as an argument)") + pkg := flag.String("package", "", "package name (default: derived from the service type)") + flag.Parse() + + if *service == "" { + fmt.Fprintln(os.Stderr, "xbot-codegen: -service is required") + flag.Usage() + os.Exit(2) + } + if *serviceID > 0xFFFF { + fmt.Fprintf(os.Stderr, "xbot-codegen: -service-id %d does not fit into uint16\n", *serviceID) + os.Exit(2) + } + + schema, err := codegen.Load(*service) + if err != nil { + fmt.Fprintf(os.Stderr, "xbot-codegen: %v\n", err) + os.Exit(1) + } + src, err := codegen.Generate(schema, codegen.Options{ServiceID: uint16(*serviceID), Package: *pkg}) + if err != nil { + fmt.Fprintf(os.Stderr, "xbot-codegen: %v\n", err) + os.Exit(1) + } + + if *out == "" { + if _, err := os.Stdout.Write(src); err != nil { + fmt.Fprintf(os.Stderr, "xbot-codegen: %v\n", err) + os.Exit(1) + } + return + } + if err := os.WriteFile(*out, src, 0o644); err != nil { + fmt.Fprintf(os.Stderr, "xbot-codegen: %v\n", err) + os.Exit(1) + } +} diff --git a/xbot_service_interface_go/codegen/emit.go b/xbot_service_interface_go/codegen/emit.go new file mode 100644 index 0000000..5d5e884 --- /dev/null +++ b/xbot_service_interface_go/codegen/emit.go @@ -0,0 +1,190 @@ +package codegen + +import ( + "fmt" + "go/format" + "strings" +) + +// Options configure Generate. +type Options struct { + // ServiceID is written as a `ServiceID` constant and used by Dial. 0 emits + // no constant: Dial then takes the id as its first argument (like the C++ + // generated interface, which gets the id at construction time). + ServiceID uint16 + // Package overrides the generated package name (default: the schema type in + // lower case, e.g. "FileService" -> "fileservice"). + Package string +} + +// Generate renders a Go client for s. +func Generate(s *Schema, opts Options) ([]byte, error) { + g := &generator{schema: s, opts: opts} + if g.opts.Package == "" { + g.opts.Package = packageName(s.Type) + } + if err := g.run(); err != nil { + return nil, err + } + return format.Source([]byte(g.body.String())) +} + +var goTypes = map[string]string{ + "uint8_t": "uint8", "uint16_t": "uint16", "uint32_t": "uint32", "uint64_t": "uint64", + "int8_t": "int8", "int16_t": "int16", "int32_t": "int32", "int64_t": "int64", + "float": "float32", "double": "float64", +} + +var typeSizes = map[string]int{ + "uint8_t": 1, "int8_t": 1, + "uint16_t": 2, "int16_t": 2, + "uint32_t": 4, "int32_t": 4, "float": 4, + "uint64_t": 8, "int64_t": 8, "double": 8, +} + +type generator struct { + schema *Schema + opts Options + body strings.Builder +} + +// line appends a line verbatim. +func (g *generator) line(s string) { g.body.WriteString(s); g.body.WriteByte('\n') } + +// f appends a formatted line. +func (g *generator) f(format string, args ...any) { + fmt.Fprintf(&g.body, format, args...) + g.body.WriteByte('\n') +} + +// packageName turns "FileService" into "fileservice". +func packageName(serviceType string) string { + var b strings.Builder + for _, r := range strings.ToLower(serviceType) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + } + } + return b.String() +} + +// enumBase returns the base type of an enum ("" if t is not an enum). +func (g *generator) enumBase(t string) string { + for _, e := range g.schema.Enums { + if e.ID == t { + return e.BaseType + } + } + return "" +} + +// goScalar maps a scalar schema type to its Go type, resolving enums. +func (g *generator) goScalar(t, where string) (string, error) { + if gt, ok := goTypes[t]; ok { + return gt, nil + } + if g.enumBase(t) != "" { + return t, nil + } + return "", fmt.Errorf("go codegen: unsupported type %q in %s", t, where) +} + +// goType maps a schema type (with optional array suffix) to a Go type. +func (g *generator) goType(t, where string) (string, error) { + base, _, isArray := splitType(t) + switch { + case base == "blob": + return "[]byte", nil + case base == "char": + return "string", nil // char[n] is a padded string on the wire + case isArray && base == "uint8_t": + return "[]byte", nil + case isArray: + return "", fmt.Errorf("go codegen: unsupported array type %q in %s", t, where) + } + return g.goScalar(base, where) +} + +// packExpr turns a Go value into the bytes of one RPC parameter. +func (g *generator) packExpr(t, expr, where string) (string, error) { + base, length, isArray := splitType(t) + switch { + case base == "blob": + return expr, nil + case base == "char": + return fmt.Sprintf("charParam(%s, %d)", expr, length), nil + case isArray: + return fmt.Sprintf("byteParam(%s, %d)", expr, length), nil + } + if eb := g.enumBase(base); eb != "" { + gt := goTypes[eb] + return fmt.Sprintf("le%s(%s(%s))", upperFirst(gt), gt, expr), nil + } + gt, err := g.goScalar(base, where) + if err != nil { + return "", err + } + return fmt.Sprintf("le%s(%s)", upperFirst(gt), expr), nil +} + +// decodeReturn returns the Go return type ("" for void) and the lines that decode +// the RPC response payload. +func (g *generator) decodeReturn(t, fn string) (string, []string, error) { + base, _, isArray := splitType(t) + switch { + case t == "" || t == "void": + return "", nil, nil + case base == "char" && isArray: + return "string", []string{"\treturn charValue(payload), nil"}, nil + case isArray && base == "uint8_t": + return "[]byte", []string{"\treturn append([]byte(nil), payload...), nil"}, nil + case isArray: + return "", nil, fmt.Errorf("go codegen: unsupported array return %q of %s", t, fn) + } + goType, err := g.goScalar(base, "return value of "+fn) + if err != nil { + return "", nil, err + } + wireType := base + if eb := g.enumBase(base); eb != "" { + wireType = eb + } + size := typeSizes[wireType] + read := fmt.Sprintf("binary.LittleEndian.Uint%d(payload[:%d])", size*8, size) + lines := []string{ + fmt.Sprintf("\tif len(payload) < %d {", size), + fmt.Sprintf("\t\treturn ZERO, fmt.Errorf(%q, len(payload))", + fmt.Sprintf("%s: %s returned %%d bytes, want %d", g.opts.Package, fn, size)), + "\t}", + } + switch { + case wireType == "uint8_t": + lines = append(lines, "\treturn payload[0], nil") + case strings.HasPrefix(wireType, "int"): + lines = append(lines, fmt.Sprintf("\treturn int%d(%s), nil", size*8, read)) + case strings.HasPrefix(wireType, "float") || wireType == "double": + lines = append(lines, fmt.Sprintf("\treturn math.Float%dfrombits(%s), nil", size*8, read)) + default: + lines = append(lines, fmt.Sprintf("\treturn %s, nil", read)) + } + return goType, lines, nil +} + +// zeroValue is the Go zero value of a return type, used in the error path. +func zeroValue(goType string) string { + switch goType { + case "string": + return `""` + case "[]byte": + return "nil" + } + return "0" +} + +// upperFirst capitalises the first letter ("uint8" -> "Uint8"). +func upperFirst(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} diff --git a/xbot_service_interface_go/codegen/generate_test.go b/xbot_service_interface_go/codegen/generate_test.go new file mode 100644 index 0000000..859fcda --- /dev/null +++ b/xbot_service_interface_go/codegen/generate_test.go @@ -0,0 +1,94 @@ +package codegen + +import ( + "bytes" + "flag" + "os" + "testing" +) + +var update = flag.Bool("update", false, "rewrite the golden files") + +// TestGenerateGolden pins the generated output for the demo schema (the same +// service.json the C++ templates use). Run with -update after an intentional +// change and review the diff. +func TestGenerateGolden(t *testing.T) { + cases := []struct { + name string + schema string + golden string + options Options + }{ + {"demo", "testdata/demo_service.json", "testdata/demo_service.golden.go", + Options{ServiceID: 99}}, + {"demo without service id", "testdata/demo_service.json", + "testdata/demo_service_noid.golden.go", Options{}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + schema, err := Load(c.schema) + if err != nil { + t.Fatalf("Load: %v", err) + } + got, err := Generate(schema, c.options) + if err != nil { + t.Fatalf("Generate: %v", err) + } + if *update { + if err := os.WriteFile(c.golden, got, 0o644); err != nil { + t.Fatalf("write golden: %v", err) + } + return + } + want, err := os.ReadFile(c.golden) + if err != nil { + t.Fatalf("read golden (run with -update): %v", err) + } + if !bytes.Equal(got, want) { + t.Errorf("generated output differs from %s (run with -update and review the diff)", c.golden) + } + }) + } +} + +// TestLoadRejects makes sure a schema the C++ generator would reject fails here +// as well, instead of producing a client that cannot work. +func TestLoadRejects(t *testing.T) { + cases := map[string]string{ + "missing type": `{"version": 1}`, + "unknown type": `{"type": "X", "version": 1, "inputs": [{"id": 0, "name": "A", "type": "uint7_t"}]}`, + "blob as input": `{"type": "X", "version": 1, "inputs": [{"id": 0, "name": "A", "type": "blob"}]}`, + "duplicate ids": `{"type": "X", "version": 1, "inputs": [{"id": 1, "name": "A", "type": "uint8_t"}, {"id": 1, "name": "B", "type": "uint8_t"}]}`, + "name collision": `{"type": "X", "version": 1, "inputs": [{"id": 0, "name": "My Input", "type": "uint8_t"}, {"id": 1, "name": "MyInput", "type": "uint8_t"}]}`, + "unknown enum base": `{"type": "X", "version": 1, "enums": [{"id": "E", "base_type": "uint7_t", "values": {"A": 0}}]}`, + } + for name, schema := range cases { + t.Run(name, func(t *testing.T) { + path := t.TempDir() + "/service.json" + if err := os.WriteFile(path, []byte(schema), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err == nil { + t.Fatal("expected an error") + } + }) + } +} + +// TestGenerateRejectsUnsupportedArrays pins that an unsupported schema fails +// loudly instead of generating code that cannot compile. +func TestGenerateRejectsUnsupportedArrays(t *testing.T) { + path := t.TempDir() + "/service.json" + schema := `{"type": "X", "version": 1, "functions": [{"id": 0, "name": "F", "return_type": "void", +"parameters": [{"id": 0, "name": "P", "type": "uint16_t[4]"}]}]}` + if err := os.WriteFile(path, []byte(schema), 0o644); err != nil { + t.Fatal(err) + } + s, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if _, err := Generate(s, Options{}); err == nil { + t.Fatal("expected an error for uint16_t[4]") + } +} diff --git a/xbot_service_interface_go/codegen/render.go b/xbot_service_interface_go/codegen/render.go new file mode 100644 index 0000000..c0dbb49 --- /dev/null +++ b/xbot_service_interface_go/codegen/render.go @@ -0,0 +1,278 @@ +package codegen + +import ( + "fmt" + "strings" +) + +// run emits the whole file: header, ids, enums, the Client, one method per RPC +// function, register setters, input senders and the wire helpers. +func (g *generator) run() error { + s := g.schema + + g.line("// Code generated by xbot-codegen from the service.json. DO NOT EDIT.") + g.line("//") + g.line("// Regenerate with:") + g.line("//") + g.line("//\txbot-codegen -service [-service-id ] -out .go") + g.line("") + g.f("// Package %s is a client for the %s service (service.json version %d).", + g.opts.Package, s.Type, s.Version) + g.f("package %s", g.opts.Package) + g.line("") + g.line(`import ( + "context" + "encoding/binary" + "fmt" + "math" + + "github.com/xtech/xbot_framework/xbot_service_interface_go/xbot" +)`) + g.line("") + + if g.opts.ServiceID != 0 { + g.f("// ServiceID is the id of the %s service (service_ids.h).", s.Type) + g.f("const ServiceID uint16 = %d", g.opts.ServiceID) + g.line("") + } + + g.line(`// Function ids (service.json -> "functions").`) + g.line("const (") + for _, fn := range s.Functions { + g.f("\tFn%s uint8 = %d", normalizeName(fn.Name), fn.ID) + } + g.line(")") + g.line("") + + for _, e := range s.Enums { + if err := g.emitEnum(e); err != nil { + return err + } + } + + g.line("// Client is a client for the " + s.Type + " service.") + g.line("type Client struct{ conn *xbot.Conn }") + g.line("") + g.line("// Dial connects to the service; it claims it unless opts.NoClaim is set.") + if g.opts.ServiceID != 0 { + g.line("func Dial(ctx context.Context, opts xbot.Options) (*Client, error) {") + g.line("\topts.ServiceID = ServiceID") + } else { + g.line("func Dial(ctx context.Context, serviceID uint16, opts xbot.Options) (*Client, error) {") + g.line("\topts.ServiceID = serviceID") + } + g.line("\tconn, err := xbot.Dial(ctx, opts)") + g.line("\tif err != nil {") + g.line("\t\treturn nil, err") + g.line("\t}") + g.line("\treturn &Client{conn: conn}, nil") + g.line("}") + g.line("") + g.line("// Close closes the connection.") + g.line("func (c *Client) Close() error { return c.conn.Close() }") + g.line("") + g.line("// Endpoint returns the \"ip:port\" of the service.") + g.line("func (c *Client) Endpoint() string { return c.conn.Endpoint() }") + g.line("") + + for _, fn := range s.Functions { + if err := g.emitFunction(fn); err != nil { + return err + } + } + for _, r := range s.Registers { + if err := g.emitRegister(r); err != nil { + return err + } + } + for _, in := range s.Inputs { + if err := g.emitInput(in); err != nil { + return err + } + } + if len(s.Outputs) > 0 { + g.f("// The service has %d outputs; receiving them needs a receive loop in", len(s.Outputs)) + g.line("// xbot.Conn, which this client does not have yet.") + g.line("") + } + g.emitHelpers() + return nil +} + +// emitEnum writes the type, its constants and (optionally) the String method. +func (g *generator) emitEnum(e Enum) error { + base, ok := goTypes[e.BaseType] + if !ok { + return fmt.Errorf("go codegen: enum %s: unknown base_type %q", e.ID, e.BaseType) + } + g.f("// %s values (service.json -> \"enums\").", e.ID) + g.f("type %s %s", e.ID, base) + g.line("") + g.line("const (") + for _, name := range sortedEnumValues(e) { + lit := fmt.Sprintf("%d", e.Values[name]) + if e.Bitmask { + lit = "1 << " + lit + } + g.f("\t%s%s %s = %s", e.ID, name, e.ID, lit) + } + g.line(")") + g.line("") + if !e.ToString { + return nil + } + g.line("// String returns the name of the value.") + g.f("func (v %s) String() string {", e.ID) + g.line("\tswitch v {") + seen := map[int64]bool{} + for _, name := range sortedEnumValues(e) { + if seen[e.Values[name]] { + continue + } + seen[e.Values[name]] = true + g.f("\tcase %s%s:", e.ID, name) + g.f("\t\treturn %q", strings.ToLower(name)) + } + g.line("\t}") + g.line("\treturn \"\"") + g.line("}") + g.line("") + return nil +} + +// emitFunction writes one RPC method. +func (g *generator) emitFunction(fn Function) error { + name := normalizeName(fn.Name) + sig := []string{"ctx context.Context"} + var args []string + for _, p := range fn.Parameters { + pname := normalizeName(p.Name) + where := "parameter " + pname + " of " + name + gt, err := g.goType(p.Type, where) + if err != nil { + return err + } + expr, err := g.packExpr(p.Type, pname, where) + if err != nil { + return err + } + sig = append(sig, pname+" "+gt) + args = append(args, fmt.Sprintf("\t\txbot.Bytes(%d, %s),", p.ID, expr)) + } + retType, lines, err := g.decodeReturn(fn.ReturnType, name) + if err != nil { + return err + } + ret := "error" + if retType != "" { + ret = "(" + retType + ", error)" + } + + g.f("// %s calls the RPC function %d.", name, fn.ID) + g.f("func (c *Client) %s(%s) %s {", name, strings.Join(sig, ", "), ret) + assign := "\tpayload, err := " + if retType == "" { + assign = "\t_, err := " + } + if len(args) > 0 { + g.f("%sc.conn.Call(ctx, Fn%s,", assign, name) + for _, a := range args { + g.line(a) + } + g.line("\t)") + } else { + g.f("%sc.conn.Call(ctx, Fn%s)", assign, name) + } + if retType == "" { + g.line("\treturn err") + } else { + g.line("\tif err != nil {") + g.f("\t\treturn %s, err", zeroValue(retType)) + g.line("\t}") + for _, l := range lines { + g.line(strings.Replace(l, "ZERO", zeroValue(retType), 1)) + } + } + g.line("}") + g.line("") + return nil +} + +// emitRegister writes a setter that sends the register as a configuration +// transaction (the service reconfigures and persists it). +func (g *generator) emitRegister(r Field) error { + name := normalizeName(r.Name) + where := "register " + name + gt, err := g.goType(r.Type, where) + if err != nil { + return err + } + expr, err := g.packExpr(r.Type, name, where) + if err != nil { + return err + } + g.f("// Set%s writes the register %d (\"%s\") as a configuration transaction.", + name, r.ID, name) + g.f("func (c *Client) Set%s(%s %s) error {", name, name, gt) + g.f("\treturn c.conn.SendTransaction(true, xbot.Data{ID: %d, Data: %s})", r.ID, expr) + g.line("}") + g.line("") + return nil +} + +// emitInput writes an input sender. +func (g *generator) emitInput(in Field) error { + name := normalizeName(in.Name) + where := "input " + name + gt, err := g.goType(in.Type, where) + if err != nil { + return err + } + expr, err := g.packExpr(in.Type, name, where) + if err != nil { + return err + } + g.f("// Send%s sends the input %d (\"%s\").", name, in.ID, name) + g.f("func (c *Client) Send%s(%s %s) error {", name, name, gt) + g.f("\treturn c.conn.SendData(%d, %s)", in.ID, expr) + g.line("}") + g.line("") + return nil +} + +// emitHelpers writes the wire helpers. They are always emitted, so the import +// list of the generated file never depends on the schema. +func (g *generator) emitHelpers() { + g.line("// ---- wire helpers ----------------------------------------------------------") + g.line("") + g.line("func leUint8(v uint8) []byte { return []byte{v} }") + g.line("func leUint16(v uint16) []byte { b := make([]byte, 2); binary.LittleEndian.PutUint16(b, v); return b }") + g.line("func leUint32(v uint32) []byte { b := make([]byte, 4); binary.LittleEndian.PutUint32(b, v); return b }") + g.line("func leUint64(v uint64) []byte { b := make([]byte, 8); binary.LittleEndian.PutUint64(b, v); return b }") + g.line("func leInt8(v int8) []byte { return []byte{uint8(v)} }") + g.line("func leInt16(v int16) []byte { return leUint16(uint16(v)) }") + g.line("func leInt32(v int32) []byte { return leUint32(uint32(v)) }") + g.line("func leInt64(v int64) []byte { return leUint64(uint64(v)) }") + g.line("func leFloat32(v float32) []byte { return leUint32(math.Float32bits(v)) }") + g.line("func leFloat64(v float64) []byte { return leUint64(math.Float64bits(v)) }") + g.line("") + g.line("// byteParam pads b with NUL to the fixed size of a T[n] parameter (n <= 0: as is).") + g.line("func byteParam(b []byte, n int) []byte {") + g.line("\tif n <= 0 || len(b) >= n {") + g.line("\t\treturn b") + g.line("\t}") + g.line("\treturn append(b, make([]byte, n-len(b))...)") + g.line("}") + g.line("") + g.line("// charParam pads s with NUL to the size of a char[n] parameter (n <= 0: as is).") + g.line("func charParam(s string, n int) []byte { return byteParam([]byte(s), n) }") + g.line("") + g.line("// charValue trims the NUL padding of a char[n] value.") + g.line("func charValue(b []byte) string {") + g.line("\tfor len(b) > 0 && b[len(b)-1] == 0 {") + g.line("\t\tb = b[:len(b)-1]") + g.line("\t}") + g.line("\treturn string(b)") + g.line("}") + g.line("") +} diff --git a/xbot_service_interface_go/codegen/schema.go b/xbot_service_interface_go/codegen/schema.go new file mode 100644 index 0000000..7f7c1cc --- /dev/null +++ b/xbot_service_interface_go/codegen/schema.go @@ -0,0 +1,219 @@ +// Package codegen generates Go service clients from a xbot service.json. +// +// It is the Go counterpart of the cog templates in codegen/templates: it reads +// the same schema, applies the same rules (normalized names, unique ids, type +// whitelist, array limits) and emits a client that composes the generic +// transport of this module (xbot.Conn) with the ids and typed calls of one +// service. +package codegen + +import ( + "encoding/json" + "fmt" + "os" + "regexp" + "sort" + "strings" +) + +// Schema is a service.json. +type Schema struct { + Type string `json:"type"` + Version int `json:"version"` + Enums []Enum `json:"enums"` + Inputs []Field `json:"inputs"` + Outputs []Field `json:"outputs"` + Registers []Field `json:"registers"` + Functions []Function `json:"functions"` +} + +// Enum is one entry of "enums". Bitmask enums store shifts instead of values. +type Enum struct { + ID string `json:"id"` + BaseType string `json:"base_type"` + Values map[string]int64 `json:"values"` + Bitmask bool `json:"bitmask"` + ToString bool `json:"to_string"` +} + +// Field is an input, output or register. +type Field struct { + ID int `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Optional bool `json:"optional"` +} + +// Function is one RPC function. +type Function struct { + ID int `json:"id"` + Name string `json:"name"` + ReturnType string `json:"return_type"` + Parameters []Parameter `json:"parameters"` +} + +// Parameter is one RPC parameter. +type Parameter struct { + ID int `json:"id"` + Name string `json:"name"` + Type string `json:"type"` +} + +// rawTypes are the scalar types the wire format supports (C++: xbot_codegen.py). +var rawTypes = []string{ + "char", "uint8_t", "uint16_t", "uint32_t", "uint64_t", + "int8_t", "int16_t", "int32_t", "int64_t", "float", "double", +} + +// maxArrayLength is the limit the framework applies to array returns. +const maxArrayLength = 1024 + +var arrayType = regexp.MustCompile(`^(.+)\[(\d+)\]$`) + +// Load reads and validates a service.json. +func Load(path string) (*Schema, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + s := &Schema{} + if err := json.Unmarshal(data, s); err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + if err := s.validate(); err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + return s, nil +} + +// validate applies the same checks the C++ generator does, so a schema that +// builds for C++ also generates for Go (and a broken one fails loudly here). +func (s *Schema) validate() error { + if s.Type == "" { + return fmt.Errorf("service.json: missing \"type\"") + } + for _, f := range s.Functions { + if len(f.Parameters) > 0 { + if err := checkUniqueIDs("function "+f.Name, f.Parameters); err != nil { + return err + } + } + // Array returns are limited like in the C++ generator. + if base, n, isArray := splitType(f.ReturnType); isArray && base != "char" { + if n < 1 || n > maxArrayLength { + return fmt.Errorf("function %s: return length %d must be 1..%d", f.Name, n, maxArrayLength) + } + } + } + if err := checkUniqueIDs("inputs", s.Inputs); err != nil { + return err + } + if err := checkUniqueIDs("outputs", s.Outputs); err != nil { + return err + } + if err := checkUniqueIDs("registers", s.Registers); err != nil { + return err + } + + types := map[string]bool{} + for _, t := range rawTypes { + types[t] = true + } + for _, e := range s.Enums { + if !types[e.BaseType] { + return fmt.Errorf("enum %s: unknown base_type %q", e.ID, e.BaseType) + } + types[e.ID] = true + } + for _, set := range []struct { + what string + fields []Field + blob bool + }{ + {"input", s.Inputs, false}, {"output", s.Outputs, false}, {"register", s.Registers, true}, + } { + for _, f := range set.fields { + base, _, isArray := splitType(f.Type) + if base == "blob" { + if !set.blob { + return fmt.Errorf("%s %s: \"blob\" is only allowed for registers", set.what, f.Name) + } + if isArray { + return fmt.Errorf("%s %s: \"blob\" cannot be an array", set.what, f.Name) + } + continue + } + if !types[base] { + return fmt.Errorf("%s %s: unknown type %q", set.what, f.Name, f.Type) + } + } + } + return nil +} + +// checkUniqueIDs rejects duplicate ids and duplicate normalized names, which the +// generated Go methods would otherwise collide on. +func checkUniqueIDs(what string, things interface{}) error { + seenIDs := map[int]bool{} + seenNames := map[string]string{} + switch v := things.(type) { + case []Parameter: + for _, p := range v { + if err := addUnique(seenIDs, seenNames, what, p.ID, p.Name); err != nil { + return err + } + } + case []Field: + for _, f := range v { + if err := addUnique(seenIDs, seenNames, what, f.ID, f.Name); err != nil { + return err + } + } + } + return nil +} + +func addUnique(ids map[int]bool, names map[string]string, what string, id int, name string) error { + if ids[id] { + return fmt.Errorf("%s: duplicate id %d", what, id) + } + ids[id] = true + normalized := normalizeName(name) + if other, ok := names[normalized]; ok { + return fmt.Errorf("%s: %q and %q normalize to the same Go name %q", what, other, name, normalized) + } + names[normalized] = name + return nil +} + +// normalizeName removes whitespace, exactly like xbot_codegen.toCamelCase: the +// generated method name for "File Exists" is "FileExists". +func normalizeName(name string) string { + return strings.Join(strings.Fields(name), "") +} + +// splitType splits "char[128]" into ("char", 128, true). +func splitType(t string) (base string, length int, isArray bool) { + if m := arrayType.FindStringSubmatch(t); m != nil { + var n int + _, _ = fmt.Sscanf(m[2], "%d", &n) + return m[1], n, true + } + return t, 0, false +} + +// sortedEnumValues returns the enum values ordered by their numeric value, so +// the generated const block is deterministic. +func sortedEnumValues(e Enum) []string { + keys := make([]string, 0, len(e.Values)) + for k := range e.Values { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + if e.Values[keys[i]] != e.Values[keys[j]] { + return e.Values[keys[i]] < e.Values[keys[j]] + } + return keys[i] < keys[j] + }) + return keys +} diff --git a/xbot_service_interface_go/codegen/testdata/demo_service.golden.go b/xbot_service_interface_go/codegen/testdata/demo_service.golden.go new file mode 100644 index 0000000..4bfea9d --- /dev/null +++ b/xbot_service_interface_go/codegen/testdata/demo_service.golden.go @@ -0,0 +1,175 @@ +// Code generated by xbot-codegen from the service.json. DO NOT EDIT. +// +// Regenerate with: +// +// xbot-codegen -service [-service-id ] -out .go + +// Package servicetemplate is a client for the ServiceTemplate service (service.json version 1). +package servicetemplate + +import ( + "context" + "encoding/binary" + "fmt" + "math" + + "github.com/xtech/xbot_framework/xbot_service_interface_go/xbot" +) + +// ServiceID is the id of the ServiceTemplate service (service_ids.h). +const ServiceID uint16 = 99 + +// Function ids (service.json -> "functions"). +const ( + FnNoParamsNoReturn uint8 = 0 + FnScalarParamsWithReturn uint8 = 1 + FnArrayParamNoReturn uint8 = 2 + FnMixedParamsWithReturn uint8 = 3 +) + +// ExampleEnumClass values (service.json -> "enums"). +type ExampleEnumClass uint8 + +const ( + ExampleEnumClassVALUE1 ExampleEnumClass = 0 + ExampleEnumClassVALUE2 ExampleEnumClass = 1 + ExampleEnumClassVALUE3 ExampleEnumClass = 2 +) + +// ExampleBitmaskEnum values (service.json -> "enums"). +type ExampleBitmaskEnum uint8 + +const ( + ExampleBitmaskEnumVALUE1 ExampleBitmaskEnum = 1 << 0 + ExampleBitmaskEnumVALUE2 ExampleBitmaskEnum = 1 << 1 + ExampleBitmaskEnumVALUE3 ExampleBitmaskEnum = 1 << 2 +) + +// Client is a client for the ServiceTemplate service. +type Client struct{ conn *xbot.Conn } + +// Dial connects to the service; it claims it unless opts.NoClaim is set. +func Dial(ctx context.Context, opts xbot.Options) (*Client, error) { + opts.ServiceID = ServiceID + conn, err := xbot.Dial(ctx, opts) + if err != nil { + return nil, err + } + return &Client{conn: conn}, nil +} + +// Close closes the connection. +func (c *Client) Close() error { return c.conn.Close() } + +// Endpoint returns the "ip:port" of the service. +func (c *Client) Endpoint() string { return c.conn.Endpoint() } + +// NoParamsNoReturn calls the RPC function 0. +func (c *Client) NoParamsNoReturn(ctx context.Context) error { + _, err := c.conn.Call(ctx, FnNoParamsNoReturn) + return err +} + +// ScalarParamsWithReturn calls the RPC function 1. +func (c *Client) ScalarParamsWithReturn(ctx context.Context, Speed float32, Count uint32, Enable uint8) (int32, error) { + payload, err := c.conn.Call(ctx, FnScalarParamsWithReturn, + xbot.Bytes(0, leFloat32(Speed)), + xbot.Bytes(1, leUint32(Count)), + xbot.Bytes(2, leUint8(Enable)), + ) + if err != nil { + return 0, err + } + if len(payload) < 4 { + return 0, fmt.Errorf("servicetemplate: ScalarParamsWithReturn returned %d bytes, want 4", len(payload)) + } + return int32(binary.LittleEndian.Uint32(payload[:4])), nil +} + +// ArrayParamNoReturn calls the RPC function 2. +func (c *Client) ArrayParamNoReturn(ctx context.Context, Label string) error { + _, err := c.conn.Call(ctx, FnArrayParamNoReturn, + xbot.Bytes(0, charParam(Label, 64)), + ) + return err +} + +// MixedParamsWithReturn calls the RPC function 3. +func (c *Client) MixedParamsWithReturn(ctx context.Context, Name string, Value float32) (uint8, error) { + payload, err := c.conn.Call(ctx, FnMixedParamsWithReturn, + xbot.Bytes(0, charParam(Name, 32)), + xbot.Bytes(1, leFloat32(Value)), + ) + if err != nil { + return 0, err + } + if len(payload) < 1 { + return 0, fmt.Errorf("servicetemplate: MixedParamsWithReturn returned %d bytes, want 1", len(payload)) + } + return payload[0], nil +} + +// SetRegister1 writes the register 0 ("Register1") as a configuration transaction. +func (c *Client) SetRegister1(Register1 string) error { + return c.conn.SendTransaction(true, xbot.Data{ID: 0, Data: charParam(Register1, 42)}) +} + +// SetRegister2 writes the register 1 ("Register2") as a configuration transaction. +func (c *Client) SetRegister2(Register2 uint32) error { + return c.conn.SendTransaction(true, xbot.Data{ID: 1, Data: leUint32(Register2)}) +} + +// SetRegister3 writes the register 2 ("Register3") as a configuration transaction. +func (c *Client) SetRegister3(Register3 []byte) error { + return c.conn.SendTransaction(true, xbot.Data{ID: 2, Data: Register3}) +} + +// SetRegister4Optional writes the register 3 ("Register4Optional") as a configuration transaction. +func (c *Client) SetRegister4Optional(Register4Optional uint32) error { + return c.conn.SendTransaction(true, xbot.Data{ID: 3, Data: leUint32(Register4Optional)}) +} + +// SendExampleInput1 sends the input 0 ("ExampleInput1"). +func (c *Client) SendExampleInput1(ExampleInput1 string) error { + return c.conn.SendData(0, charParam(ExampleInput1, 100)) +} + +// SendExampleInput2 sends the input 1 ("ExampleInput2"). +func (c *Client) SendExampleInput2(ExampleInput2 uint32) error { + return c.conn.SendData(1, leUint32(ExampleInput2)) +} + +// The service has 2 outputs; receiving them needs a receive loop in +// xbot.Conn, which this client does not have yet. + +// ---- wire helpers ---------------------------------------------------------- + +func leUint8(v uint8) []byte { return []byte{v} } +func leUint16(v uint16) []byte { b := make([]byte, 2); binary.LittleEndian.PutUint16(b, v); return b } +func leUint32(v uint32) []byte { b := make([]byte, 4); binary.LittleEndian.PutUint32(b, v); return b } +func leUint64(v uint64) []byte { b := make([]byte, 8); binary.LittleEndian.PutUint64(b, v); return b } +func leInt8(v int8) []byte { return []byte{uint8(v)} } +func leInt16(v int16) []byte { return leUint16(uint16(v)) } +func leInt32(v int32) []byte { return leUint32(uint32(v)) } +func leInt64(v int64) []byte { return leUint64(uint64(v)) } +func leFloat32(v float32) []byte { return leUint32(math.Float32bits(v)) } +func leFloat64(v float64) []byte { return leUint64(math.Float64bits(v)) } + +// byteParam pads b with NUL to the fixed size of a T[n] parameter (n <= 0: as is). +func byteParam(b []byte, n int) []byte { + if n <= 0 || len(b) >= n { + return b + } + return append(b, make([]byte, n-len(b))...) +} + +// charParam pads s with NUL to the size of a char[n] parameter (n <= 0: as is). +func charParam(s string, n int) []byte { return byteParam([]byte(s), n) } + +// charValue trims the NUL padding of a char[n] value. +func charValue(b []byte) string { + for len(b) > 0 && b[len(b)-1] == 0 { + b = b[:len(b)-1] + } + return string(b) +} diff --git a/xbot_service_interface_go/codegen/testdata/demo_service.json b/xbot_service_interface_go/codegen/testdata/demo_service.json new file mode 100644 index 0000000..8014f8b --- /dev/null +++ b/xbot_service_interface_go/codegen/testdata/demo_service.json @@ -0,0 +1,107 @@ +{ + "type": "ServiceTemplate", + "version": 1, + "inputs": [ + { + "id": 0, + "name": "ExampleInput1", + "type": "char[100]" + }, + { + "id": 1, + "name": "ExampleInput2", + "type": "uint32_t" + } + ], + "outputs": [ + { + "id": 0, + "name": "ExampleOutput1", + "type": "char[100]" + }, + { + "id": 1, + "name": "ExampleOutput2", + "type": "uint32_t" + } + ], + "registers": [ + { + "id": 0, + "name": "Register1", + "type": "char[42]" + }, + { + "id": 1, + "name": "Register2", + "type": "uint32_t" + }, + { + "id": 2, + "name": "Register3", + "type": "blob" + }, + { + "id": 3, + "name": "Register4 Optional", + "type": "uint32_t", + "optional": true + } + ], + "functions": [ + { + "id": 0, + "name": "NoParamsNoReturn", + "parameters": [], + "return_type": "void" + }, + { + "id": 1, + "name": "ScalarParamsWithReturn", + "parameters": [ + { "id": 0, "name": "Speed", "type": "float" }, + { "id": 1, "name": "Count", "type": "uint32_t" }, + { "id": 2, "name": "Enable", "type": "uint8_t" } + ], + "return_type": "int32_t" + }, + { + "id": 2, + "name": "ArrayParamNoReturn", + "parameters": [ + { "id": 0, "name": "Label", "type": "char[64]" } + ], + "return_type": "void" + }, + { + "id": 3, + "name": "MixedParamsWithReturn", + "parameters": [ + { "id": 0, "name": "Name", "type": "char[32]" }, + { "id": 1, "name": "Value", "type": "float" } + ], + "return_type": "uint8_t" + } + ], + "enums": [ + { + "id": "ExampleEnumClass", + "base_type": "uint8_t", + "values": { + "VALUE1": 0, + "VALUE2": 1, + "VALUE3": 2 + } + }, + { + "id": "ExampleBitmaskEnum", + "base_type": "uint8_t", + "bitmask": true, + "values": { + "VALUE1": 0, + "VALUE2": 1, + "VALUE3": 2 + } + } + ] +} \ No newline at end of file diff --git a/xbot_service_interface_go/codegen/testdata/demo_service_noid.golden.go b/xbot_service_interface_go/codegen/testdata/demo_service_noid.golden.go new file mode 100644 index 0000000..52f5637 --- /dev/null +++ b/xbot_service_interface_go/codegen/testdata/demo_service_noid.golden.go @@ -0,0 +1,172 @@ +// Code generated by xbot-codegen from the service.json. DO NOT EDIT. +// +// Regenerate with: +// +// xbot-codegen -service [-service-id ] -out .go + +// Package servicetemplate is a client for the ServiceTemplate service (service.json version 1). +package servicetemplate + +import ( + "context" + "encoding/binary" + "fmt" + "math" + + "github.com/xtech/xbot_framework/xbot_service_interface_go/xbot" +) + +// Function ids (service.json -> "functions"). +const ( + FnNoParamsNoReturn uint8 = 0 + FnScalarParamsWithReturn uint8 = 1 + FnArrayParamNoReturn uint8 = 2 + FnMixedParamsWithReturn uint8 = 3 +) + +// ExampleEnumClass values (service.json -> "enums"). +type ExampleEnumClass uint8 + +const ( + ExampleEnumClassVALUE1 ExampleEnumClass = 0 + ExampleEnumClassVALUE2 ExampleEnumClass = 1 + ExampleEnumClassVALUE3 ExampleEnumClass = 2 +) + +// ExampleBitmaskEnum values (service.json -> "enums"). +type ExampleBitmaskEnum uint8 + +const ( + ExampleBitmaskEnumVALUE1 ExampleBitmaskEnum = 1 << 0 + ExampleBitmaskEnumVALUE2 ExampleBitmaskEnum = 1 << 1 + ExampleBitmaskEnumVALUE3 ExampleBitmaskEnum = 1 << 2 +) + +// Client is a client for the ServiceTemplate service. +type Client struct{ conn *xbot.Conn } + +// Dial connects to the service; it claims it unless opts.NoClaim is set. +func Dial(ctx context.Context, serviceID uint16, opts xbot.Options) (*Client, error) { + opts.ServiceID = serviceID + conn, err := xbot.Dial(ctx, opts) + if err != nil { + return nil, err + } + return &Client{conn: conn}, nil +} + +// Close closes the connection. +func (c *Client) Close() error { return c.conn.Close() } + +// Endpoint returns the "ip:port" of the service. +func (c *Client) Endpoint() string { return c.conn.Endpoint() } + +// NoParamsNoReturn calls the RPC function 0. +func (c *Client) NoParamsNoReturn(ctx context.Context) error { + _, err := c.conn.Call(ctx, FnNoParamsNoReturn) + return err +} + +// ScalarParamsWithReturn calls the RPC function 1. +func (c *Client) ScalarParamsWithReturn(ctx context.Context, Speed float32, Count uint32, Enable uint8) (int32, error) { + payload, err := c.conn.Call(ctx, FnScalarParamsWithReturn, + xbot.Bytes(0, leFloat32(Speed)), + xbot.Bytes(1, leUint32(Count)), + xbot.Bytes(2, leUint8(Enable)), + ) + if err != nil { + return 0, err + } + if len(payload) < 4 { + return 0, fmt.Errorf("servicetemplate: ScalarParamsWithReturn returned %d bytes, want 4", len(payload)) + } + return int32(binary.LittleEndian.Uint32(payload[:4])), nil +} + +// ArrayParamNoReturn calls the RPC function 2. +func (c *Client) ArrayParamNoReturn(ctx context.Context, Label string) error { + _, err := c.conn.Call(ctx, FnArrayParamNoReturn, + xbot.Bytes(0, charParam(Label, 64)), + ) + return err +} + +// MixedParamsWithReturn calls the RPC function 3. +func (c *Client) MixedParamsWithReturn(ctx context.Context, Name string, Value float32) (uint8, error) { + payload, err := c.conn.Call(ctx, FnMixedParamsWithReturn, + xbot.Bytes(0, charParam(Name, 32)), + xbot.Bytes(1, leFloat32(Value)), + ) + if err != nil { + return 0, err + } + if len(payload) < 1 { + return 0, fmt.Errorf("servicetemplate: MixedParamsWithReturn returned %d bytes, want 1", len(payload)) + } + return payload[0], nil +} + +// SetRegister1 writes the register 0 ("Register1") as a configuration transaction. +func (c *Client) SetRegister1(Register1 string) error { + return c.conn.SendTransaction(true, xbot.Data{ID: 0, Data: charParam(Register1, 42)}) +} + +// SetRegister2 writes the register 1 ("Register2") as a configuration transaction. +func (c *Client) SetRegister2(Register2 uint32) error { + return c.conn.SendTransaction(true, xbot.Data{ID: 1, Data: leUint32(Register2)}) +} + +// SetRegister3 writes the register 2 ("Register3") as a configuration transaction. +func (c *Client) SetRegister3(Register3 []byte) error { + return c.conn.SendTransaction(true, xbot.Data{ID: 2, Data: Register3}) +} + +// SetRegister4Optional writes the register 3 ("Register4Optional") as a configuration transaction. +func (c *Client) SetRegister4Optional(Register4Optional uint32) error { + return c.conn.SendTransaction(true, xbot.Data{ID: 3, Data: leUint32(Register4Optional)}) +} + +// SendExampleInput1 sends the input 0 ("ExampleInput1"). +func (c *Client) SendExampleInput1(ExampleInput1 string) error { + return c.conn.SendData(0, charParam(ExampleInput1, 100)) +} + +// SendExampleInput2 sends the input 1 ("ExampleInput2"). +func (c *Client) SendExampleInput2(ExampleInput2 uint32) error { + return c.conn.SendData(1, leUint32(ExampleInput2)) +} + +// The service has 2 outputs; receiving them needs a receive loop in +// xbot.Conn, which this client does not have yet. + +// ---- wire helpers ---------------------------------------------------------- + +func leUint8(v uint8) []byte { return []byte{v} } +func leUint16(v uint16) []byte { b := make([]byte, 2); binary.LittleEndian.PutUint16(b, v); return b } +func leUint32(v uint32) []byte { b := make([]byte, 4); binary.LittleEndian.PutUint32(b, v); return b } +func leUint64(v uint64) []byte { b := make([]byte, 8); binary.LittleEndian.PutUint64(b, v); return b } +func leInt8(v int8) []byte { return []byte{uint8(v)} } +func leInt16(v int16) []byte { return leUint16(uint16(v)) } +func leInt32(v int32) []byte { return leUint32(uint32(v)) } +func leInt64(v int64) []byte { return leUint64(uint64(v)) } +func leFloat32(v float32) []byte { return leUint32(math.Float32bits(v)) } +func leFloat64(v float64) []byte { return leUint64(math.Float64bits(v)) } + +// byteParam pads b with NUL to the fixed size of a T[n] parameter (n <= 0: as is). +func byteParam(b []byte, n int) []byte { + if n <= 0 || len(b) >= n { + return b + } + return append(b, make([]byte, n-len(b))...) +} + +// charParam pads s with NUL to the size of a char[n] parameter (n <= 0: as is). +func charParam(s string, n int) []byte { return byteParam([]byte(s), n) } + +// charValue trims the NUL padding of a char[n] value. +func charValue(b []byte) string { + for len(b) > 0 && b[len(b)-1] == 0 { + b = b[:len(b)-1] + } + return string(b) +} From 28b3a2959323933a8054982b36a80a3ac1de1bef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Ebeling?= Date: Wed, 28 Oct 2026 20:31:07 +0100 Subject: [PATCH 07/11] feat(codegen): support array types and validate service compatibility - Generate slice types and pack/decode helpers for non-uint8 fixed-size arrays instead of erroring out - Emit ExpectedType/Version so generated clients reject services announcing an incompatible schema - Create the output directory when running under go:generate - Only emit the numeric array helpers the generated file needs - Document new options and ErrIncompatibleService in README --- xbot_service_interface_go/README.md | 6 ++ .../cmd/xbot-codegen/main.go | 8 ++ xbot_service_interface_go/codegen/emit.go | 55 ++++++++++++-- .../codegen/generate_test.go | 23 ++++-- xbot_service_interface_go/codegen/render.go | 74 +++++++++++++++++++ .../codegen/testdata/demo_service.golden.go | 11 ++- .../testdata/demo_service_noid.golden.go | 11 ++- xbot_service_interface_go/xbot/conn.go | 28 +++++++ xbot_service_interface_go/xbot/conn_test.go | 41 ++++++++++ xbot_service_interface_go/xbot/errors.go | 24 ++++++ 10 files changed, 265 insertions(+), 16 deletions(-) diff --git a/xbot_service_interface_go/README.md b/xbot_service_interface_go/README.md index 3e8a90e..a56298c 100644 --- a/xbot_service_interface_go/README.md +++ b/xbot_service_interface_go/README.md @@ -70,6 +70,7 @@ Pass a context with a deadline, or set `Options.CallTimeout` — otherwise `Defa | `NoClaim` | connect without claiming: the owner keeps the responses, this client only sends | | `Endpoint` | `"ip:port"` — skip discovery (implies `NoClaim`) | | `CallTimeout` | default timeout for `Call` without a context deadline; `0` = 2 s | +| `ExpectType`, `ExpectVersion` | validate the discovered service against the schema the client was generated from | Discovery needs a window of a few advertisement intervals: a service advertises every second only while it is unclaimed and younger than 60 s, afterwards (and once claimed) every 10 s. @@ -102,6 +103,7 @@ descriptor. | `xbot.ErrRPC` | the call failed inside the service | | `xbot.ErrNotAdvertised` | the service id was not advertised; the message lists what was seen | | `*xbot.RPCError` | carries `FunctionID` and `Status`; matches the two above via `Is` | +| `xbot.ErrIncompatibleService` | the service announced a different type/version than the client expects (`*xbot.MismatchError`) | ### Discovery and helpers @@ -125,6 +127,10 @@ generated code adds the service id, the function ids, the enums, one method per setter per register and a sender per input. `-service-id` is optional: without it `Dial` takes the id as an argument. +The generated `Dial` sets `Options.ExpectType`/`ExpectVersion` from the schema, so a client generated +from an older `service.json` refuses a service that announces a newer version instead of sending +calls with the wrong parameter layout. + The output is `gofmt`ed and checked in (Go convention); a `go:generate` directive plus `go generate ./... && git diff --exit-code` in CI keeps it in sync with the schema. Generator and golden tests live in `codegen/`. diff --git a/xbot_service_interface_go/cmd/xbot-codegen/main.go b/xbot_service_interface_go/cmd/xbot-codegen/main.go index 0d2928e..1264807 100644 --- a/xbot_service_interface_go/cmd/xbot-codegen/main.go +++ b/xbot_service_interface_go/cmd/xbot-codegen/main.go @@ -14,6 +14,7 @@ import ( "flag" "fmt" "os" + "path/filepath" "github.com/xtech/xbot_framework/xbot_service_interface_go/codegen" ) @@ -53,6 +54,13 @@ func main() { } return } + // go:generate runs in the package directory, so create the target dir. + if dir := filepath.Dir(*out); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + fmt.Fprintf(os.Stderr, "xbot-codegen: %v\n", err) + os.Exit(1) + } + } if err := os.WriteFile(*out, src, 0o644); err != nil { fmt.Fprintf(os.Stderr, "xbot-codegen: %v\n", err) os.Exit(1) diff --git a/xbot_service_interface_go/codegen/emit.go b/xbot_service_interface_go/codegen/emit.go index 5d5e884..b43c8df 100644 --- a/xbot_service_interface_go/codegen/emit.go +++ b/xbot_service_interface_go/codegen/emit.go @@ -19,7 +19,7 @@ type Options struct { // Generate renders a Go client for s. func Generate(s *Schema, opts Options) ([]byte, error) { - g := &generator{schema: s, opts: opts} + g := &generator{schema: s, opts: opts, needs: map[string]bool{}} if g.opts.Package == "" { g.opts.Package = packageName(s.Type) } @@ -46,6 +46,9 @@ type generator struct { schema *Schema opts Options body strings.Builder + // needs collects the numeric array helpers the generated file refers to, so + // only those are emitted (the scalar helpers are always emitted). + needs map[string]bool } // line appends a line verbatim. @@ -100,7 +103,11 @@ func (g *generator) goType(t, where string) (string, error) { case isArray && base == "uint8_t": return "[]byte", nil case isArray: - return "", fmt.Errorf("go codegen: unsupported array type %q in %s", t, where) + gt, err := g.goScalar(base, where) + if err != nil { + return "", err + } + return "[]" + gt, nil } return g.goScalar(base, where) } @@ -113,8 +120,15 @@ func (g *generator) packExpr(t, expr, where string) (string, error) { return expr, nil case base == "char": return fmt.Sprintf("charParam(%s, %d)", expr, length), nil - case isArray: + case isArray && base == "uint8_t": return fmt.Sprintf("byteParam(%s, %d)", expr, length), nil + case isArray: + kind, err := g.sliceKind(base, where) + if err != nil { + return "", err + } + g.needs[kind] = true + return fmt.Sprintf("le%sSlice(%s, %d)", kind, expr, length), nil } if eb := g.enumBase(base); eb != "" { gt := goTypes[eb] @@ -139,7 +153,17 @@ func (g *generator) decodeReturn(t, fn string) (string, []string, error) { case isArray && base == "uint8_t": return "[]byte", []string{"\treturn append([]byte(nil), payload...), nil"}, nil case isArray: - return "", nil, fmt.Errorf("go codegen: unsupported array return %q of %s", t, fn) + kind, err := g.sliceKind(base, "return value of "+fn) + if err != nil { + return "", nil, err + } + g.needs[kind] = true + gt, err := g.goScalar(base, "return value of "+fn) + if err != nil { + return "", nil, err + } + _, length, _ := splitType(t) + return "[]" + gt, []string{fmt.Sprintf("\treturn read%sSlice(payload, %d)", kind, length)}, nil } goType, err := g.goScalar(base, "return value of "+fn) if err != nil { @@ -153,8 +177,8 @@ func (g *generator) decodeReturn(t, fn string) (string, []string, error) { read := fmt.Sprintf("binary.LittleEndian.Uint%d(payload[:%d])", size*8, size) lines := []string{ fmt.Sprintf("\tif len(payload) < %d {", size), - fmt.Sprintf("\t\treturn ZERO, fmt.Errorf(%q, len(payload))", - fmt.Sprintf("%s: %s returned %%d bytes, want %d", g.opts.Package, fn, size)), + fmt.Sprintf("\t\treturn ZERO, errf(%q, len(payload))", + fmt.Sprintf("%s returned %%d bytes, want %d", fn, size)), "\t}", } switch { @@ -188,3 +212,22 @@ func upperFirst(s string) string { } return strings.ToUpper(s[:1]) + s[1:] } + +// sliceKinds maps a numeric base type to the name of its array helper. +var sliceKinds = map[string]string{ + "uint16_t": "Uint16", "uint32_t": "Uint32", "uint64_t": "Uint64", + "int8_t": "Int8", "int16_t": "Int16", "int32_t": "Int32", "int64_t": "Int64", + "float": "Float32", "double": "Float64", +} + +// sliceKind returns the array helper for an element type (enums resolve to their +// base type). +func (g *generator) sliceKind(base, where string) (string, error) { + if eb := g.enumBase(base); eb != "" { + base = eb + } + if kind, ok := sliceKinds[base]; ok { + return kind, nil + } + return "", fmt.Errorf("go codegen: unsupported array element type %q in %s", base, where) +} diff --git a/xbot_service_interface_go/codegen/generate_test.go b/xbot_service_interface_go/codegen/generate_test.go index 859fcda..27b51dc 100644 --- a/xbot_service_interface_go/codegen/generate_test.go +++ b/xbot_service_interface_go/codegen/generate_test.go @@ -75,11 +75,11 @@ func TestLoadRejects(t *testing.T) { } } -// TestGenerateRejectsUnsupportedArrays pins that an unsupported schema fails -// loudly instead of generating code that cannot compile. -func TestGenerateRejectsUnsupportedArrays(t *testing.T) { +// TestNumericArrays pins that numeric arrays (T[n]) are supported: they are +// padded to n elements on send and decoded from n elements on receive. +func TestNumericArrays(t *testing.T) { path := t.TempDir() + "/service.json" - schema := `{"type": "X", "version": 1, "functions": [{"id": 0, "name": "F", "return_type": "void", + schema := `{"type": "X", "version": 1, "functions": [{"id": 0, "name": "F", "return_type": "double[3]", "parameters": [{"id": 0, "name": "P", "type": "uint16_t[4]"}]}]}` if err := os.WriteFile(path, []byte(schema), 0o644); err != nil { t.Fatal(err) @@ -88,7 +88,18 @@ func TestGenerateRejectsUnsupportedArrays(t *testing.T) { if err != nil { t.Fatalf("Load: %v", err) } - if _, err := Generate(s, Options{}); err == nil { - t.Fatal("expected an error for uint16_t[4]") + src, err := Generate(s, Options{}) + if err != nil { + t.Fatalf("Generate: %v", err) + } + for _, want := range []string{ + "func (c *Client) F(ctx context.Context, P []uint16) ([]float64, error)", + "xbot.Bytes(0, leUint16Slice(P, 4))", + "return readFloat64Slice(payload, 3)", + "func padFloat64Slice(", + } { + if !bytes.Contains(src, []byte(want)) { + t.Errorf("generated code does not contain %q", want) + } } } diff --git a/xbot_service_interface_go/codegen/render.go b/xbot_service_interface_go/codegen/render.go index c0dbb49..c1a7f42 100644 --- a/xbot_service_interface_go/codegen/render.go +++ b/xbot_service_interface_go/codegen/render.go @@ -61,6 +61,8 @@ func (g *generator) run() error { g.line("func Dial(ctx context.Context, serviceID uint16, opts xbot.Options) (*Client, error) {") g.line("\topts.ServiceID = serviceID") } + g.f("\topts.ExpectType = %q", s.Type) + g.f("\topts.ExpectVersion = %d", s.Version) g.line("\tconn, err := xbot.Dial(ctx, opts)") g.line("\tif err != nil {") g.line("\t\treturn nil, err") @@ -275,4 +277,76 @@ func (g *generator) emitHelpers() { g.line("\treturn string(b)") g.line("}") g.line("") + g.line("// errf prefixes an error of this client.") + g.line("func errf(format string, args ...any) error {") + g.f("\treturn fmt.Errorf(%q+format, args...)", g.opts.Package+": ") + g.line("}") + g.line("") + g.emitSliceHelpers() +} + +// sliceHelper describes the code of one numeric array helper. +type sliceHelper struct { + goType string + size int + put string + get string +} + +var sliceHelpers = map[string]sliceHelper{ + "Uint16": {"uint16", 2, "binary.LittleEndian.PutUint16(b[i*2:], x)", "binary.LittleEndian.Uint16(b[i*2:])"}, + "Uint32": {"uint32", 4, "binary.LittleEndian.PutUint32(b[i*4:], x)", "binary.LittleEndian.Uint32(b[i*4:])"}, + "Uint64": {"uint64", 8, "binary.LittleEndian.PutUint64(b[i*8:], x)", "binary.LittleEndian.Uint64(b[i*8:])"}, + "Int8": {"int8", 1, "b[i] = uint8(x)", "int8(b[i])"}, + "Int16": {"int16", 2, "binary.LittleEndian.PutUint16(b[i*2:], uint16(x))", "int16(binary.LittleEndian.Uint16(b[i*2:]))"}, + "Int32": {"int32", 4, "binary.LittleEndian.PutUint32(b[i*4:], uint32(x))", "int32(binary.LittleEndian.Uint32(b[i*4:]))"}, + "Int64": {"int64", 8, "binary.LittleEndian.PutUint64(b[i*8:], uint64(x))", "int64(binary.LittleEndian.Uint64(b[i*8:]))"}, + "Float32": {"float32", 4, "binary.LittleEndian.PutUint32(b[i*4:], math.Float32bits(x))", "math.Float32frombits(binary.LittleEndian.Uint32(b[i*4:]))"}, + "Float64": {"float64", 8, "binary.LittleEndian.PutUint64(b[i*8:], math.Float64bits(x))", "math.Float64frombits(binary.LittleEndian.Uint64(b[i*8:]))"}, +} + +// sliceHelperOrder is the deterministic emission order. +var sliceHelperOrder = []string{ + "Uint16", "Uint32", "Uint64", "Int8", "Int16", "Int32", "Int64", "Float32", "Float64", +} + +// emitSliceHelpers writes the numeric array helpers the schema needs: a fixed +// size T[n] is padded to n elements before encoding, and a return value is +// decoded from exactly n elements. +func (g *generator) emitSliceHelpers() { + for _, kind := range sliceHelperOrder { + if !g.needs[kind] { + continue + } + h := sliceHelpers[kind] + g.f("func pad%sSlice(v []%s, n int) []%s {", kind, h.goType, h.goType) + g.line("\tif n <= 0 || len(v) >= n {") + g.line("\t\treturn v") + g.line("\t}") + g.f("\tout := make([]%s, n)", h.goType) + g.line("\tcopy(out, v)") + g.line("\treturn out") + g.line("}") + g.line("") + g.f("func le%sSlice(v []%s, n int) []byte {", kind, h.goType) + g.f("\tv = pad%sSlice(v, n)", kind) + g.f("\tb := make([]byte, len(v)*%d)", h.size) + g.line("\tfor i, x := range v {") + g.f("\t\t%s", h.put) + g.line("\t}") + g.line("\treturn b") + g.line("}") + g.line("") + g.f("func read%sSlice(b []byte, n int) ([]%s, error) {", kind, h.goType) + g.f("\tif n <= 0 || len(b) < n*%d {", h.size) + g.f("\t\treturn nil, errf(\"array of %d elements: got %%d bytes, want %%d\", len(b), n*%d)", h.size, h.size) + g.line("\t}") + g.f("\tout := make([]%s, n)", h.goType) + g.line("\tfor i := range out {") + g.f("\t\tout[i] = %s", h.get) + g.line("\t}") + g.line("\treturn out, nil") + g.line("}") + g.line("") + } } diff --git a/xbot_service_interface_go/codegen/testdata/demo_service.golden.go b/xbot_service_interface_go/codegen/testdata/demo_service.golden.go index 4bfea9d..d190153 100644 --- a/xbot_service_interface_go/codegen/testdata/demo_service.golden.go +++ b/xbot_service_interface_go/codegen/testdata/demo_service.golden.go @@ -51,6 +51,8 @@ type Client struct{ conn *xbot.Conn } // Dial connects to the service; it claims it unless opts.NoClaim is set. func Dial(ctx context.Context, opts xbot.Options) (*Client, error) { opts.ServiceID = ServiceID + opts.ExpectType = "ServiceTemplate" + opts.ExpectVersion = 1 conn, err := xbot.Dial(ctx, opts) if err != nil { return nil, err @@ -81,7 +83,7 @@ func (c *Client) ScalarParamsWithReturn(ctx context.Context, Speed float32, Coun return 0, err } if len(payload) < 4 { - return 0, fmt.Errorf("servicetemplate: ScalarParamsWithReturn returned %d bytes, want 4", len(payload)) + return 0, errf("ScalarParamsWithReturn returned %d bytes, want 4", len(payload)) } return int32(binary.LittleEndian.Uint32(payload[:4])), nil } @@ -104,7 +106,7 @@ func (c *Client) MixedParamsWithReturn(ctx context.Context, Name string, Value f return 0, err } if len(payload) < 1 { - return 0, fmt.Errorf("servicetemplate: MixedParamsWithReturn returned %d bytes, want 1", len(payload)) + return 0, errf("MixedParamsWithReturn returned %d bytes, want 1", len(payload)) } return payload[0], nil } @@ -173,3 +175,8 @@ func charValue(b []byte) string { } return string(b) } + +// errf prefixes an error of this client. +func errf(format string, args ...any) error { + return fmt.Errorf("servicetemplate: "+format, args...) +} diff --git a/xbot_service_interface_go/codegen/testdata/demo_service_noid.golden.go b/xbot_service_interface_go/codegen/testdata/demo_service_noid.golden.go index 52f5637..8769665 100644 --- a/xbot_service_interface_go/codegen/testdata/demo_service_noid.golden.go +++ b/xbot_service_interface_go/codegen/testdata/demo_service_noid.golden.go @@ -48,6 +48,8 @@ type Client struct{ conn *xbot.Conn } // Dial connects to the service; it claims it unless opts.NoClaim is set. func Dial(ctx context.Context, serviceID uint16, opts xbot.Options) (*Client, error) { opts.ServiceID = serviceID + opts.ExpectType = "ServiceTemplate" + opts.ExpectVersion = 1 conn, err := xbot.Dial(ctx, opts) if err != nil { return nil, err @@ -78,7 +80,7 @@ func (c *Client) ScalarParamsWithReturn(ctx context.Context, Speed float32, Coun return 0, err } if len(payload) < 4 { - return 0, fmt.Errorf("servicetemplate: ScalarParamsWithReturn returned %d bytes, want 4", len(payload)) + return 0, errf("ScalarParamsWithReturn returned %d bytes, want 4", len(payload)) } return int32(binary.LittleEndian.Uint32(payload[:4])), nil } @@ -101,7 +103,7 @@ func (c *Client) MixedParamsWithReturn(ctx context.Context, Name string, Value f return 0, err } if len(payload) < 1 { - return 0, fmt.Errorf("servicetemplate: MixedParamsWithReturn returned %d bytes, want 1", len(payload)) + return 0, errf("MixedParamsWithReturn returned %d bytes, want 1", len(payload)) } return payload[0], nil } @@ -170,3 +172,8 @@ func charValue(b []byte) string { } return string(b) } + +// errf prefixes an error of this client. +func errf(format string, args ...any) error { + return fmt.Errorf("servicetemplate: "+format, args...) +} diff --git a/xbot_service_interface_go/xbot/conn.go b/xbot_service_interface_go/xbot/conn.go index 13d6538..c172035 100644 --- a/xbot_service_interface_go/xbot/conn.go +++ b/xbot_service_interface_go/xbot/conn.go @@ -78,6 +78,14 @@ type Options struct { // CallTimeout bounds Call calls whose context has no deadline. 0 means // DefaultCallTimeout. CallTimeout time.Duration + // ExpectType and ExpectVersion validate the discovered service against the + // definition the client was generated from ("FileService", 1). A mismatch - + // i.e. a firmware with a different version of that service - fails the dial + // instead of sending calls with the wrong parameter layout. Empty / 0 + // disables the check; it applies to discovery only, an explicit Endpoint has + // no advertisement to compare against. + ExpectType string + ExpectVersion int } // Conn is a unicast connection to one service. Calls and sends are safe for @@ -148,6 +156,10 @@ func Dial(ctx context.Context, opts Options) (*Conn, error) { _ = udp.Close() return nil, err } + if err := checkAdvertisement(adv, opts); err != nil { + _ = udp.Close() + return nil, err + } c.svcIP, c.svcPort = adv.IP, adv.Port if !opts.NoClaim { @@ -353,3 +365,19 @@ func isTimeout(err error) bool { var ne net.Error return errors.As(err, &ne) && ne.Timeout() } + +// checkAdvertisement validates a discovered service against the definition the +// client was generated from. The "version" in the service.json is the wire level +// compatibility contract - the same check the C++ and Python clients do, and the +// reason a client generated from an old schema refuses a newer firmware instead +// of sending calls with the wrong parameter layout. +func checkAdvertisement(adv Advertisement, opts Options) error { + if opts.ExpectType != "" && adv.Type != opts.ExpectType { + return &MismatchError{Endpoint: adv.Endpoint(), Field: "type", Got: adv.Type, Want: opts.ExpectType} + } + if opts.ExpectVersion != 0 && adv.Version != opts.ExpectVersion { + return &MismatchError{Endpoint: adv.Endpoint(), Field: "version", + Got: strconv.Itoa(adv.Version), Want: strconv.Itoa(opts.ExpectVersion)} + } + return nil +} diff --git a/xbot_service_interface_go/xbot/conn_test.go b/xbot_service_interface_go/xbot/conn_test.go index 5a01c88..9ff2264 100644 --- a/xbot_service_interface_go/xbot/conn_test.go +++ b/xbot_service_interface_go/xbot/conn_test.go @@ -264,3 +264,44 @@ func TestSendTransactionLayout(t *testing.T) { t.Fatalf("payload =\n% x\nwant\n% x", gotPayload, want) } } + +// TestCheckAdvertisement pins the wire level compatibility contract: a client +// generated from schema version N rejects a service that announces another type +// or version, and does nothing when the check is disabled. +func TestCheckAdvertisement(t *testing.T) { + adv := Advertisement{ServiceID: 12, Type: "FileService", Version: 1, IP: "10.0.0.2", Port: 4242} + + if err := checkAdvertisement(adv, Options{}); err != nil { + t.Fatalf("disabled check must pass: %v", err) + } + if err := checkAdvertisement(adv, Options{ExpectType: "FileService", ExpectVersion: 1}); err != nil { + t.Fatalf("matching service rejected: %v", err) + } + + for _, c := range []struct { + name string + opts Options + want string + }{ + {"other type", Options{ExpectType: "SoundService"}, "type"}, + {"other version", Options{ExpectVersion: 2}, "version"}, + } { + err := checkAdvertisement(adv, c.opts) + if err == nil { + t.Fatalf("%s: expected an error", c.name) + } + if !errors.Is(err, ErrIncompatibleService) { + t.Errorf("%s: err = %v, want errors.Is(err, ErrIncompatibleService)", c.name, err) + } + var mismatch *MismatchError + if !errors.As(err, &mismatch) { + t.Fatalf("%s: err = %v, want a *MismatchError", c.name, err) + } + if mismatch.Field != c.want { + t.Errorf("%s: field = %q, want %q", c.name, mismatch.Field, c.want) + } + if mismatch.Endpoint != "10.0.0.2:4242" { + t.Errorf("%s: endpoint = %q", c.name, mismatch.Endpoint) + } + } +} diff --git a/xbot_service_interface_go/xbot/errors.go b/xbot_service_interface_go/xbot/errors.go index 59add3e..cbef6b1 100644 --- a/xbot_service_interface_go/xbot/errors.go +++ b/xbot_service_interface_go/xbot/errors.go @@ -49,3 +49,27 @@ func (e *RPCError) Is(target error) bool { return false } } + +// ErrIncompatibleService is matched by a MismatchError: the service on the bus +// implements a different definition than the client was generated from. +var ErrIncompatibleService = errors.New("xbot: incompatible service") + +// MismatchError reports a discovered service whose type or version differs from +// the definition the client was generated from. +type MismatchError struct { + // Field is "type" or "version". + Field string + // Want is what the client was generated for, Got what the service announced. + Want, Got string + // Endpoint is the address of the service that answered. + Endpoint string +} + +// Error implements error. +func (e *MismatchError) Error() string { + return fmt.Sprintf("xbot: service at %s has %s %s, but this client was generated for %s %s "+ + "(regenerate the client or update the firmware)", e.Endpoint, e.Field, e.Got, e.Field, e.Want) +} + +// Is makes the error match ErrIncompatibleService. +func (e *MismatchError) Is(target error) bool { return target == ErrIncompatibleService } From 589bf0c7937f961033f2fd203d8ee44653a1193c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Ebeling?= Date: Wed, 28 Oct 2026 21:16:36 +0100 Subject: [PATCH 08/11] Mke pre-commit happy --- .../codegen/testdata/demo_service.json | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/xbot_service_interface_go/codegen/testdata/demo_service.json b/xbot_service_interface_go/codegen/testdata/demo_service.json index 8014f8b..f701edf 100644 --- a/xbot_service_interface_go/codegen/testdata/demo_service.json +++ b/xbot_service_interface_go/codegen/testdata/demo_service.json @@ -59,9 +59,21 @@ "id": 1, "name": "ScalarParamsWithReturn", "parameters": [ - { "id": 0, "name": "Speed", "type": "float" }, - { "id": 1, "name": "Count", "type": "uint32_t" }, - { "id": 2, "name": "Enable", "type": "uint8_t" } + { + "id": 0, + "name": "Speed", + "type": "float" + }, + { + "id": 1, + "name": "Count", + "type": "uint32_t" + }, + { + "id": 2, + "name": "Enable", + "type": "uint8_t" + } ], "return_type": "int32_t" }, @@ -69,7 +81,11 @@ "id": 2, "name": "ArrayParamNoReturn", "parameters": [ - { "id": 0, "name": "Label", "type": "char[64]" } + { + "id": 0, + "name": "Label", + "type": "char[64]" + } ], "return_type": "void" }, @@ -77,8 +93,16 @@ "id": 3, "name": "MixedParamsWithReturn", "parameters": [ - { "id": 0, "name": "Name", "type": "char[32]" }, - { "id": 1, "name": "Value", "type": "float" } + { + "id": 0, + "name": "Name", + "type": "char[32]" + }, + { + "id": 1, + "name": "Value", + "type": "float" + } ], "return_type": "uint8_t" } From 16e6d5d9f4ca9184bea737ba201b873cbed9d5ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Ebeling?= Date: Wed, 28 Oct 2026 21:25:36 +0100 Subject: [PATCH 09/11] Make pre-commit happy --- .gitignore | 1 + .../codegen/testdata/demo_service.json | 124 +++++++++--------- 2 files changed, 63 insertions(+), 62 deletions(-) diff --git a/.gitignore b/.gitignore index 782ae74..892bff6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ codegen/xbot_codegen/__pycache__ build/ venv/ .venv +.vscode/settings.json diff --git a/xbot_service_interface_go/codegen/testdata/demo_service.json b/xbot_service_interface_go/codegen/testdata/demo_service.json index f701edf..23f406e 100644 --- a/xbot_service_interface_go/codegen/testdata/demo_service.json +++ b/xbot_service_interface_go/codegen/testdata/demo_service.json @@ -1,51 +1,23 @@ { - "type": "ServiceTemplate", - "version": 1, - "inputs": [ - { - "id": 0, - "name": "ExampleInput1", - "type": "char[100]" - }, - { - "id": 1, - "name": "ExampleInput2", - "type": "uint32_t" - } - ], - "outputs": [ - { - "id": 0, - "name": "ExampleOutput1", - "type": "char[100]" - }, - { - "id": 1, - "name": "ExampleOutput2", - "type": "uint32_t" - } - ], - "registers": [ - { - "id": 0, - "name": "Register1", - "type": "char[42]" - }, - { - "id": 1, - "name": "Register2", - "type": "uint32_t" - }, + "enums": [ { - "id": 2, - "name": "Register3", - "type": "blob" + "base_type": "uint8_t", + "id": "ExampleEnumClass", + "values": { + "VALUE1": 0, + "VALUE2": 1, + "VALUE3": 2 + } }, { - "id": 3, - "name": "Register4 Optional", - "type": "uint32_t", - "optional": true + "base_type": "uint8_t", + "bitmask": true, + "id": "ExampleBitmaskEnum", + "values": { + "VALUE1": 0, + "VALUE2": 1, + "VALUE3": 2 + } } ], "functions": [ @@ -107,25 +79,53 @@ "return_type": "uint8_t" } ], - "enums": [ + "inputs": [ { - "id": "ExampleEnumClass", - "base_type": "uint8_t", - "values": { - "VALUE1": 0, - "VALUE2": 1, - "VALUE3": 2 - } + "id": 0, + "name": "ExampleInput1", + "type": "char[100]" }, { - "id": "ExampleBitmaskEnum", - "base_type": "uint8_t", - "bitmask": true, - "values": { - "VALUE1": 0, - "VALUE2": 1, - "VALUE3": 2 - } + "id": 1, + "name": "ExampleInput2", + "type": "uint32_t" + } + ], + "outputs": [ + { + "id": 0, + "name": "ExampleOutput1", + "type": "char[100]" + }, + { + "id": 1, + "name": "ExampleOutput2", + "type": "uint32_t" } - ] -} \ No newline at end of file + ], + "registers": [ + { + "id": 0, + "name": "Register1", + "type": "char[42]" + }, + { + "id": 1, + "name": "Register2", + "type": "uint32_t" + }, + { + "id": 2, + "name": "Register3", + "type": "blob" + }, + { + "id": 3, + "name": "Register4 Optional", + "optional": true, + "type": "uint32_t" + } + ], + "type": "ServiceTemplate", + "version": 1 +} From fc69d02617142d89727d00224ccbee00a3c2fb9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Ebeling?= Date: Wed, 28 Oct 2026 21:32:27 +0100 Subject: [PATCH 10/11] Make pre-commit happy --- codegen/xbot_codegen/xbot_codegen.py | 1 - 1 file changed, 1 deletion(-) diff --git a/codegen/xbot_codegen/xbot_codegen.py b/codegen/xbot_codegen/xbot_codegen.py index b9db06c..ffaaf3c 100644 --- a/codegen/xbot_codegen/xbot_codegen.py +++ b/codegen/xbot_codegen/xbot_codegen.py @@ -255,4 +255,3 @@ def generateEnums(service): cog.outl(" default: return \"\";") cog.outl(" }") cog.outl("}") - From ed6e9a51689f1a7c8d4c42376f403ec53877a17c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Ebeling?= Date: Wed, 28 Oct 2026 21:48:40 +0100 Subject: [PATCH 11/11] fix(codegen): reject enums with no values in schema validation Both the Python and Go generators now fail early with a clear error when an enum has an empty values map. Previously the to_string path attempted to derive a member count from max(values), causing an opaque ValueError during code emission. A test case is added to cover the Go validator. --- codegen/xbot_codegen/xbot_codegen.py | 5 +++++ xbot_service_interface_go/codegen/generate_test.go | 1 + xbot_service_interface_go/codegen/schema.go | 5 +++++ 3 files changed, 11 insertions(+) diff --git a/codegen/xbot_codegen/xbot_codegen.py b/codegen/xbot_codegen/xbot_codegen.py index ffaaf3c..b561b8c 100644 --- a/codegen/xbot_codegen/xbot_codegen.py +++ b/codegen/xbot_codegen/xbot_codegen.py @@ -111,6 +111,11 @@ def loadService(path: str) -> dict: # Transform enums valid_types = raw_encoding_valid_types for enum in json_service["enums"]: + # An enum without values has no members to generate, and the to_string + # path below derives its count from max(values) - so reject it here + # instead of failing with a ValueError while emitting code. + if not enum.get("values"): + raise Exception(f"Enum '{enum.get('id', '?')}' has no values") valid_types.append(enum["id"]) service["enums"].append({ "id": enum["id"], diff --git a/xbot_service_interface_go/codegen/generate_test.go b/xbot_service_interface_go/codegen/generate_test.go index 27b51dc..d839b39 100644 --- a/xbot_service_interface_go/codegen/generate_test.go +++ b/xbot_service_interface_go/codegen/generate_test.go @@ -61,6 +61,7 @@ func TestLoadRejects(t *testing.T) { "duplicate ids": `{"type": "X", "version": 1, "inputs": [{"id": 1, "name": "A", "type": "uint8_t"}, {"id": 1, "name": "B", "type": "uint8_t"}]}`, "name collision": `{"type": "X", "version": 1, "inputs": [{"id": 0, "name": "My Input", "type": "uint8_t"}, {"id": 1, "name": "MyInput", "type": "uint8_t"}]}`, "unknown enum base": `{"type": "X", "version": 1, "enums": [{"id": "E", "base_type": "uint7_t", "values": {"A": 0}}]}`, + "empty enum": `{"type": "X", "version": 1, "enums": [{"id": "E", "base_type": "uint8_t", "to_string": true, "values": {}}]}`, } for name, schema := range cases { t.Run(name, func(t *testing.T) { diff --git a/xbot_service_interface_go/codegen/schema.go b/xbot_service_interface_go/codegen/schema.go index 7f7c1cc..006c86c 100644 --- a/xbot_service_interface_go/codegen/schema.go +++ b/xbot_service_interface_go/codegen/schema.go @@ -120,6 +120,11 @@ func (s *Schema) validate() error { types[t] = true } for _, e := range s.Enums { + if len(e.Values) == 0 { + // Same rule as the C++ generator: an enum without members cannot be + // generated (and the to_string path would derive a count from nothing). + return fmt.Errorf("enum %s: no values", e.ID) + } if !types[e.BaseType] { return fmt.Errorf("enum %s: unknown base_type %q", e.ID, e.BaseType) }