Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions .github/workflows/go.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
name: go

# The Go module is part of a polyglot repository: `xbot/message_type_generated.go` mirrors
# `enum class MessageType` in include/xbot/datatypes/XbotHeader.hpp. Regenerating and
# committing that here means a developer can add a message type (or a service definition)
# without installing Go - the same pattern as in the definitions repository.
#
# Fix and verification share one run on purpose: a push made with GITHUB_TOKEN does not
# trigger further workflow runs.
on:
pull_request:
push:
Expand All @@ -8,20 +15,34 @@ on:
permissions:
contents: read

# A push to a branch with an open pull request fires both events, and github.ref differs
# between them, so they have to share one group per branch to serialize the commit back.
concurrency:
group: go-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.head_ref || github.ref_name }}
cancel-in-progress: false

jobs:
test:
runs-on: ubuntu-latest
# The regenerate step commits back; every other step stays read-only.
permissions:
contents: write
defaults:
run:
working-directory: xbot_service_interface_go
steps:
# Format, vet, test and build check what merging the pull request would produce, so they
# run on the default checkout (for a pull_request event that is the synthetic merge
# commit). Only the regenerate step below needs the head branch, because that is where
# its commit goes.
- 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 .)"
Expand All @@ -36,3 +57,68 @@ jobs:
run: go test ./... -race -count=1
- name: Build examples
run: go build ./...

# The merge commit is detached and only exists in this repository, so the fix has to be
# worked on in the head branch's own worktree. !cancelled() on purpose: a red Format or
# Test above is exactly what a stale generated file looks like, and the commit below is
# the fix - the bot push then triggers a run that sees the corrected branch.
- name: Checkout the branch to regenerate on
if: ${{ !cancelled() }}
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
# A fork PR branch only exists in the fork.
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
ref: ${{ github.head_ref || github.ref_name }}
persist-credentials: false

- name: Regenerate the Go sources
id: regenerate
if: ${{ !cancelled() }}
env:
BRANCH: ${{ github.head_ref || github.ref_name }}
# Only a branch we may push to: not the default branch, and not a fork.
CAN_COMMIT: >-
${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)
|| (github.event_name == 'push' && github.ref_name != github.event.repository.default_branch) }}
run: |
go generate ./...
cd ..
# Only the Go module is generated; the C++ header is the author's edit.
if git diff --quiet -- xbot_service_interface_go &&
[ -z "$(git ls-files --others --exclude-standard -- xbot_service_interface_go)" ]; then
echo "::notice::The Go sources already match the C++ header."
exit 0
fi
if [ "$CAN_COMMIT" != "true" ]; then
git --no-pager diff --stat -- xbot_service_interface_go
echo "::error::The Go sources do not match the C++ header and this run must not commit them (default branch or fork). Run 'cd xbot_service_interface_go && go generate ./...' and commit the result."
exit 1
fi
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git add -A -- xbot_service_interface_go
git commit -m 'chore(go): regenerate the message types from XbotHeader.hpp'
echo "committed=true" >> "$GITHUB_OUTPUT"

- name: Push the regenerated sources
# Separate step so the token never sits in the environment of the step that runs
# `go generate` on a possibly forked branch.
if: steps.regenerate.outputs.committed == 'true'
env:
# GITHUB_TOKEN pushes do not trigger workflow runs, so the regenerated commit
# would carry no check runs; a PAT (contents: write) in BOT_TOKEN does.
BOT_TOKEN: ${{ secrets.BOT_TOKEN }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BRANCH: ${{ github.head_ref || github.ref_name }}
run: |
if [ -n "${BOT_TOKEN}" ]; then
token="${BOT_TOKEN}"
else
token="${GH_TOKEN}"
echo "::warning::BOT_TOKEN is not set, pushing with GITHUB_TOKEN - the regenerated commit will not trigger a workflow run, so required status checks stay pending."
fi
if ! git push "https://x-access-token:${token}@github.com/${GITHUB_REPOSITORY}.git" "HEAD:${BRANCH}"; then
echo "::error::Could not push the regenerated sources to ${BRANCH}."
exit 1
fi
echo "::notice::Committed the regenerated Go sources to ${BRANCH}."
94 changes: 53 additions & 41 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,33 +80,41 @@ message-specific payload.

### XbotHeader (24 bytes, packed)

| Offset | Size | Field | Notes |
|--------|------|------------------|---------------------------------------------------------|
| 0 | 1 | protocol_version | Currently `1` |
| 1 | 1 | message_type | See message type table below |
| Offset | Size | Field | Notes |
| ------ | ---- | ---------------- | --------------------------------------------------------- |
| 0 | 1 | protocol_version | Currently `1` |
| 1 | 1 | message_type | See message type table below |
| 2 | 1 | flags | Bit 0: reboot flag (set on boot, cleared on seq rollover) |
| 3 | 1 | reserved1 | |
| 4 | 2 | service_id | Identifies the service |
| 6 | 1 | arg1 | Message-specific (see table below) |
| 7 | 1 | reserved2 | |
| 8 | 2 | arg2 | Message-specific (see table below) |
| 10 | 2 | sequence_no | Increments per message; rollover clears reboot flag |
| 12 | 8 | timestamp | Unix timestamp in **microseconds** |
| 20 | 4 | payload_size | Byte length of payload following the header |
| 3 | 1 | reserved1 | |
| 4 | 2 | service_id | Identifies the service |
| 6 | 1 | arg1 | Message-specific (see table below) |
| 7 | 1 | reserved2 | |
| 8 | 2 | arg2 | Message-specific (see table below) |
| 10 | 2 | sequence_no | Increments per message; rollover clears reboot flag |
| 12 | 8 | timestamp | Unix timestamp in **microseconds** |
| 20 | 4 | payload_size | Byte length of payload following the header |

### Message Types

| Value | Name | arg1 | arg2 | Payload |
|--------|-------------------------|-----------------------------|-------------|----------------------------------------|
| `0x00` | UNKNOWN | — | — | — |
| `0x01` | DATA | — | `target_id` | Raw value bytes |
| `0x02` | CONFIGURATION_REQUEST | — | — | Empty |
| `0x03` | CLAIM | `0`=request, `1`=ack | — | `ClaimPayload` (request) or empty (ack) |
| `0x04` | HEARTBEAT | — | — | Empty |
| `0x05` | TRANSACTION | `0`=data, `1`=configuration | — | Sequence of `DataDescriptor`+payload |
| `0x7F` | LOG | log level (1–7) | — | UTF-8 string (max 255 bytes) |
| `0x80` | SERVICE_ADVERTISEMENT | — | — | CBOR-encoded service JSON |
| `0x81` | SERVICE_QUERY | — | — | — |
| Value | Name | arg1 | arg2 | Payload |
| ------ | --------------------- | --------------------------- | ----------- | --------------------------------------- |
| `0x00` | UNKNOWN | — | — | — |
| `0x01` | DATA | — | `target_id` | Raw value bytes |
| `0x02` | CONFIGURATION_REQUEST | — | — | Empty |
| `0x03` | CLAIM | `0`=request, `1`=ack | — | `ClaimPayload` (request) or empty (ack) |
| `0x04` | HEARTBEAT | — | — | Empty |
| `0x05` | TRANSACTION | `0`=data, `1`=configuration | — | Sequence of `DataDescriptor`+payload |
| `0x7F` | LOG | log level (1–7) | — | UTF-8 string (max 255 bytes) |
| `0x80` | SERVICE_ADVERTISEMENT | — | — | CBOR-encoded service JSON |
| `0x81` | SERVICE_QUERY | — | — | — |
| `0x82` | DESCRIPTION_REQUEST | offset into the omitted desc | `max_size` | `DescriptionRequestPayload` (ip:port) |
| `0x83` | DESCRIPTION_PART | offset into the omitted desc | `total_size`| Window of the omitted description |

`DESCRIPTION_REQUEST` is sent unicast by a host that needs the part of the schema the
advertisement could not carry (see *Service description* below); the service answers with one
`DESCRIPTION_PART` window and the host repeats until `offset + payload_size >= total`. A
service whose whole schema fits into one advertisement reports size 0 and ignores the
request.

### TRANSACTION payload format

Expand All @@ -115,7 +123,7 @@ A TRANSACTION payload is a sequence of chunks, each preceded by a `DataDescripto
**DataDescriptor (8 bytes, packed):**

| Offset | Size | Field |
|--------|------|--------------|
| ------ | ---- | ------------ |
| 0 | 2 | target_id |
| 2 | 2 | reserved |
| 4 | 4 | payload_size |
Expand All @@ -126,22 +134,22 @@ Chunks are read sequentially until `payload_size` bytes of the transaction are c

Sent by the interface as the payload of a `CLAIM` message:

| Offset | Size | Field | Notes |
|--------|------|-------------------|--------------------------------|
| 0 | 4 | target_ip | Interface IP (network byte order) |
| 4 | 2 | target_port | Interface UDP port |
| 6 | 4 | heartbeat_micros | Requested heartbeat interval |
| Offset | Size | Field | Notes |
| ------ | ---- | ---------------- | --------------------------------- |
| 0 | 4 | target_ip | Interface IP (network byte order) |
| 4 | 2 | target_port | Interface UDP port |
| 6 | 4 | heartbeat_micros | Requested heartbeat interval |

---

## Service Discovery

Service discovery uses **UDP multicast**.

| Parameter | Value |
|-------------------|-----------------|
| Multicast address | `233.255.255.0` |
| Port | `4242` |
| Parameter | Value |
| ----------------- | ------------------------------ |
| Multicast address | `233.255.255.0` |
| Port | `4242` |
| Message type | `SERVICE_ADVERTISEMENT` (0x80) |

**Advertisement payload:** CBOR-encoded JSON with the following structure:
Expand Down Expand Up @@ -179,18 +187,18 @@ Service discovery uses **UDP multicast**.

Remote logging uses **UDP multicast**.

| Parameter | Value |
|-------------------|-------------------|
| Multicast address | `233.255.255.1` |
| Port | `4242` |
| Message type | `LOG` (0x7F) |
| Parameter | Value |
| ----------------- | --------------- |
| Multicast address | `233.255.255.1` |
| Port | `4242` |
| Message type | `LOG` (0x7F) |

**Payload:** UTF-8 string, max 255 bytes. When a `service_id` context is available the message is prefixed as `[ID=X] message`.

**Log levels (arg1):**

| arg1 | Level |
|------|----------|
| ---- | -------- |
| 1 | TRACE |
| 2 | DEBUG |
| 3 | INFO |
Expand Down Expand Up @@ -277,8 +285,12 @@ Two ways to use a service:

- **Schema provided:** pass a `service.json` file (or dict) to `ServiceInterface`. The schema is validated against
the service's advertisement on connect, and an `IncompatibleServiceError` aborts the connection on a mismatch.
- **Schema-free:** omit the schema entirely. The full service description is embedded in the advertisement itself, so
inputs, outputs, registers, and RPC functions are all available immediately after discovery.
- **Schema-free:** omit the schema entirely. The advertisement carries as much of the schema as fits
into one UDP packet; a service whose schema is bigger lists the blocks it had to leave out under
`omitted` - any of `enums`, `functions`, `registers`, `outputs`, `inputs`, whatever did not fit.
The client fetches those blocks with `DESCRIPTION_REQUEST`/`DESCRIPTION_PART` right after discovery
(unicast, one round trip per window) and caches them per `(service id, version)`.
Hosts with generated classes never fetch: they know the types.

```python
from xbot_service_interface import XbotServiceIo, ServiceInterface
Expand Down
14 changes: 14 additions & 0 deletions codegen/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,17 @@ from the `service.json` stays visible, so the schema remains the reference a rea
(`// PlayMP3 calls the RPC function 3 ("PlayMp3" in the service.json).`),
- trailing comments on the enum values (`SoundIDBootPing SoundID = 0 // BOOT_PING`),
- nothing on the `Fn*` function-id constants: they are only used together with the RPC method whose doc comment names the schema function.

## The advertised service description

The advertisement is a single UDP packet (`xbot::config::max_packet_size`, 1472 bytes for one
unfragmented Ethernet frame), so the code generator advertises as much of the schema as fits and
lists the rest under `omitted`:

```json
"desc": { "type": "SoundService", "version": 1, "inputs": [...], "functions": [...],
"registers": [...], "omitted": ["enums"] }
```

Blocks are added in schema order while the budget is not exceeded; whatever does not fit is
available through `DESCRIPTION_REQUEST` -> `DESCRIPTION_PART` (see `libxbot-service`), served from a second static array. The two arrays partition the schema, so nothing is stored twice and nothing is lost: a host that needs the block (a client without generated classes) fetches it, a host with generated code never asks. A service whose schema fits is advertised completely and ignores description requests.
2 changes: 2 additions & 0 deletions codegen/templates/ServiceTemplate.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ cog.outl(f'#include "{service["class_name"]}.hpp"')

/*[[[cog
cog.outl(f"constexpr unsigned char {service['class_name']}::SERVICE_DESCRIPTION_CBOR[];")
cog.outl(f"constexpr unsigned char {service['class_name']}::SERVICE_OMITTED_CBOR[];")
]]]*/
constexpr unsigned char ServiceTemplateBase::SERVICE_DESCRIPTION_CBOR[];
constexpr unsigned char ServiceTemplateBase::SERVICE_OMITTED_CBOR[];
//[[[end]]]


Expand Down
13 changes: 13 additions & 0 deletions codegen/templates/ServiceTemplate.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,19 @@ class ServiceTemplateBase : public xbot::service::Service {
/*[[[cog
cog.out("static constexpr unsigned char SERVICE_DESCRIPTION_CBOR[] = ");
cog.outl(xbot_codegen.binary2c_array(service["service_cbor"]));
cog.outl("")
cog.outl(" // Blocks that did not fit into the advertisement; served on request")
cog.outl(" // (DESCRIPTION_REQUEST -> DESCRIPTION_PART), so a host without generated")
cog.outl(" // classes can still fetch the complete schema.")
cog.out(" static constexpr unsigned char SERVICE_OMITTED_CBOR[] = ");
cog.outl(xbot_codegen.binary2c_array(service["omitted_cbor"]));
cog.outl(" static constexpr size_t SERVICE_OMITTED_CBOR_SIZE = %d;" %
(len(service["omitted_cbor"]) if service["omitted_names"] else 0))
cog.outl("")
cog.outl(" const unsigned char* GetOmittedDescription(size_t& size) override {")
cog.outl(" size = SERVICE_OMITTED_CBOR_SIZE;")
cog.outl(" return SERVICE_OMITTED_CBOR;")
cog.outl(" }")
]]]*/
static constexpr unsigned char SERVICE_DESCRIPTION_CBOR[] = {
0xA7, 0x64, 0x74, 0x79, 0x70, 0x65, 0x6F, 0x53,
Expand Down
Loading
Loading