diff --git a/codegen/README.md b/codegen/README.md index 6a5a9af..5de502b 100644 --- a/codegen/README.md +++ b/codegen/README.md @@ -34,6 +34,14 @@ Not supported yet: outputs (DATA received from the service) need a receive loop and unknown array element types are rejected while generating (loudly) instead of producing broken code. +Generated RPC methods wait for the answer only when the client owns the service: with +`Options.NoClaim` or an explicit `Endpoint` they switch to `SendRPC`, because the service sends its +answer to the owner. + +`char[n]` parameters are padded to `n` bytes; a longer string would be sent unchanged and silently +rejected by the firmware, so the method checks the length first and returns +`Path too long (140 bytes, max 127)`. The limit is `n - 1`, because `char[n]` holds the terminating NUL. + Identifiers are translated to the Go naming rules for initialisms (`SoundId` -> `SoundID`, `GpsQuality` -> `GPSQuality`; see `xbot_service_interface_go/codegen/names.go`). The original name from the `service.json` stays visible, so the schema remains the reference a reader greps for: diff --git a/xbot_service_interface_go/README.md b/xbot_service_interface_go/README.md index dec55c6..7ee0d0d 100644 --- a/xbot_service_interface_go/README.md +++ b/xbot_service_interface_go/README.md @@ -158,6 +158,12 @@ Doc comments carry it on types, RPC methods, register setters and input senders; RPC method whose doc comment names the schema function. So `grep` on the schema name always finds the Go identifier, and a reader can go from `service.json` to the client without knowing the rules. +**Ownership.** A generated client waits for the RPC answer only while it owns the service. With +`Options.NoClaim` (or an explicit `Endpoint`) the service sends its answers to the owner, so the +generated methods use `SendRPC` and return the zero value instead of blocking on a reply that will +never arrive. That is what makes the SoundService usable from a tool like `soundctl`, which must not +claim the service the high-level system drives. + ## Scope The client implements what a tool like `soundctl` needs: discovery, claim, heartbeat, RPC (synchronous and diff --git a/xbot_service_interface_go/codegen/emit.go b/xbot_service_interface_go/codegen/emit.go index baefc95..319fe7a 100644 --- a/xbot_service_interface_go/codegen/emit.go +++ b/xbot_service_interface_go/codegen/emit.go @@ -195,11 +195,14 @@ func (g *generator) decodeReturn(t, fn string) (string, []string, error) { } // zeroValue is the Go zero value of a return type, used in the error path. +// zeroValue is the Go zero value of a generated return type: "" for strings, nil +// for slices ([]byte like the uint8_t arrays, and the numeric array types such as +// []float64) and 0 for everything else (numeric scalars and enums). func zeroValue(goType string) string { - switch goType { - case "string": + switch { + case goType == "string": return `""` - case "[]byte": + case strings.HasPrefix(goType, "[]"): return "nil" } return "0" diff --git a/xbot_service_interface_go/codegen/render.go b/xbot_service_interface_go/codegen/render.go index 2e012f2..fa6217c 100644 --- a/xbot_service_interface_go/codegen/render.go +++ b/xbot_service_interface_go/codegen/render.go @@ -60,7 +60,13 @@ func (g *generator) run() error { } g.line("// Client is a client for the " + s.Type + " service.") - g.line("type Client struct{ conn *xbot.Conn }") + g.line("type Client struct {") + g.line("\tconn *xbot.Conn") + g.line("\t// sendOnly is set when this client does not own the service (NoClaim or") + g.line("\t// Endpoint): the service sends its RPC answers to the owner, so the calls") + g.line("\t// are fire and forget.") + g.line("\tsendOnly bool") + g.line("}") g.line("") g.line("// Dial connects to the service; it claims it unless opts.NoClaim is set.") if g.opts.ServiceID != 0 { @@ -76,7 +82,7 @@ func (g *generator) run() error { g.line("\tif err != nil {") g.line("\t\treturn nil, err") g.line("\t}") - g.line("\treturn &Client{conn: conn}, nil") + g.line("\treturn &Client{conn: conn, sendOnly: opts.NoClaim || opts.Endpoint != \"\"}, nil") g.line("}") g.line("") g.line("// Close closes the connection.") @@ -162,11 +168,21 @@ func (g *generator) emitEnum(e Enum) error { return nil } +// charCheck is a char[n] parameter that needs a length check before it is packed: +// byteParam pads short values but sends longer ones unchanged, and the firmware +// then rejects them without a word, so the check turns that into a clear error. +type charCheck struct { + param string + schema string + length int +} + // emitFunction writes one RPC method. func (g *generator) emitFunction(fn Function) error { name := goName(fn.Name) sig := []string{"ctx context.Context"} - var args []string + var params []string + var charChecks []charCheck for _, p := range fn.Parameters { pname := goName(p.Name) where := "parameter " + pname + " of " + name @@ -178,39 +194,67 @@ func (g *generator) emitFunction(fn Function) error { if err != nil { return err } + if base, length, _ := splitType(p.Type); base == "char" && length > 0 { + charChecks = append(charChecks, charCheck{param: pname, schema: p.Type, length: length}) + } sig = append(sig, pname+" "+gt) - args = append(args, fmt.Sprintf("\t\txbot.Bytes(%d, %s),", p.ID, expr)) + params = append(params, fmt.Sprintf("xbot.Bytes(%d, %s),", p.ID, expr)) } retType, lines, err := g.decodeReturn(fn.ReturnType, name) if err != nil { return err } ret := "error" + zero := "" if retType != "" { ret = "(" + retType + ", error)" + zero = zeroValue(retType) + ", " + } + + // call emits one call to the service, indented by indent, using callName as + // the method on the connection (Call or SendRPC). + call := func(indent, assign, callName string) { + ctxArg := "ctx, " + if callName == "SendRPC" { + ctxArg = "" + } + if len(params) > 0 { + g.f("%s%sc.conn.%s(%sFn%s,", indent, assign, callName, ctxArg, name) + for _, param := range params { + g.line(indent + " " + param) + } + g.line(indent + ")") + return + } + g.f("%s%sc.conn.%s(%sFn%s)", indent, assign, callName, ctxArg, name) } g.f("// %s calls the RPC function %d%s.", name, fn.ID, schemaRef(fn.Name, name)) + g.line("//") + g.line("// It only waits for the answer when this client owns the service. Without a claim") + g.line("// (Options.NoClaim or Options.Endpoint) the service sends its answer to the owner,") + g.line("// so the call is fire and forget and returns the zero value.") 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) + for _, c := range charChecks { + g.f("\tif len(%s) >= %d { // %s includes the NUL", c.param, c.length, c.schema) + g.f("\t\treturn %serrf(\"%s too long (%%d bytes, max %%d)\", len(%s), %d)", + zero, c.param, c.param, c.length-1) + g.line("\t}") } + + g.line(" if c.sendOnly {") if retType == "" { - g.line("\treturn err") + call(" ", "return ", "SendRPC") + g.line(" }") + call(" ", "_, err := ", "Call") + g.line(" return err") } else { - g.line("\tif err != nil {") - g.f("\t\treturn %s, err", zeroValue(retType)) - g.line("\t}") + call(" ", "return "+zero, "SendRPC") + g.line(" }") + call(" ", "payload, err := ", "Call") + g.line(" if err != nil {") + g.f(" return %s, err", zeroValue(retType)) + g.line(" }") for _, l := range lines { g.line(strings.Replace(l, "ZERO", zeroValue(retType), 1)) } 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 2524ef9..5cf1af3 100644 --- a/xbot_service_interface_go/codegen/testdata/demo_service.golden.go +++ b/xbot_service_interface_go/codegen/testdata/demo_service.golden.go @@ -25,6 +25,7 @@ const ( FnScalarParamsWithReturn uint8 = 1 FnArrayParamNoReturn uint8 = 2 FnMixedParamsWithReturn uint8 = 3 + FnNumericArrayReturn uint8 = 4 ) // ExampleEnumClass values (service.json -> "enums"). @@ -46,7 +47,13 @@ const ( ) // Client is a client for the ServiceTemplate service. -type Client struct{ conn *xbot.Conn } +type Client struct { + conn *xbot.Conn + // sendOnly is set when this client does not own the service (NoClaim or + // Endpoint): the service sends its RPC answers to the owner, so the calls + // are fire and forget. + sendOnly bool +} // Dial connects to the service; it claims it unless opts.NoClaim is set. func Dial(ctx context.Context, opts xbot.Options) (*Client, error) { @@ -57,7 +64,7 @@ func Dial(ctx context.Context, opts xbot.Options) (*Client, error) { if err != nil { return nil, err } - return &Client{conn: conn}, nil + return &Client{conn: conn, sendOnly: opts.NoClaim || opts.Endpoint != ""}, nil } // Close closes the connection. @@ -67,13 +74,31 @@ func (c *Client) Close() error { return c.conn.Close() } func (c *Client) Endpoint() string { return c.conn.Endpoint() } // NoParamsNoReturn calls the RPC function 0. +// +// It only waits for the answer when this client owns the service. Without a claim +// (Options.NoClaim or Options.Endpoint) the service sends its answer to the owner, +// so the call is fire and forget and returns the zero value. func (c *Client) NoParamsNoReturn(ctx context.Context) error { + if c.sendOnly { + return c.conn.SendRPC(FnNoParamsNoReturn) + } _, err := c.conn.Call(ctx, FnNoParamsNoReturn) return err } // ScalarParamsWithReturn calls the RPC function 1. +// +// It only waits for the answer when this client owns the service. Without a claim +// (Options.NoClaim or Options.Endpoint) the service sends its answer to the owner, +// so the call is fire and forget and returns the zero value. func (c *Client) ScalarParamsWithReturn(ctx context.Context, Speed float32, Count uint32, Enable uint8) (int32, error) { + if c.sendOnly { + return 0, c.conn.SendRPC(FnScalarParamsWithReturn, + xbot.Bytes(0, leFloat32(Speed)), + xbot.Bytes(1, leUint32(Count)), + xbot.Bytes(2, leUint8(Enable)), + ) + } payload, err := c.conn.Call(ctx, FnScalarParamsWithReturn, xbot.Bytes(0, leFloat32(Speed)), xbot.Bytes(1, leUint32(Count)), @@ -89,7 +114,19 @@ func (c *Client) ScalarParamsWithReturn(ctx context.Context, Speed float32, Coun } // ArrayParamNoReturn calls the RPC function 2. +// +// It only waits for the answer when this client owns the service. Without a claim +// (Options.NoClaim or Options.Endpoint) the service sends its answer to the owner, +// so the call is fire and forget and returns the zero value. func (c *Client) ArrayParamNoReturn(ctx context.Context, Label string) error { + if len(Label) >= 64 { // char[64] includes the NUL + return errf("Label too long (%d bytes, max %d)", len(Label), 63) + } + if c.sendOnly { + return c.conn.SendRPC(FnArrayParamNoReturn, + xbot.Bytes(0, charParam(Label, 64)), + ) + } _, err := c.conn.Call(ctx, FnArrayParamNoReturn, xbot.Bytes(0, charParam(Label, 64)), ) @@ -97,7 +134,20 @@ func (c *Client) ArrayParamNoReturn(ctx context.Context, Label string) error { } // MixedParamsWithReturn calls the RPC function 3. +// +// It only waits for the answer when this client owns the service. Without a claim +// (Options.NoClaim or Options.Endpoint) the service sends its answer to the owner, +// so the call is fire and forget and returns the zero value. func (c *Client) MixedParamsWithReturn(ctx context.Context, Name string, Value float32) (uint8, error) { + if len(Name) >= 32 { // char[32] includes the NUL + return 0, errf("Name too long (%d bytes, max %d)", len(Name), 31) + } + if c.sendOnly { + return 0, c.conn.SendRPC(FnMixedParamsWithReturn, + xbot.Bytes(0, charParam(Name, 32)), + xbot.Bytes(1, leFloat32(Value)), + ) + } payload, err := c.conn.Call(ctx, FnMixedParamsWithReturn, xbot.Bytes(0, charParam(Name, 32)), xbot.Bytes(1, leFloat32(Value)), @@ -111,6 +161,22 @@ func (c *Client) MixedParamsWithReturn(ctx context.Context, Name string, Value f return payload[0], nil } +// NumericArrayReturn calls the RPC function 4. +// +// It only waits for the answer when this client owns the service. Without a claim +// (Options.NoClaim or Options.Endpoint) the service sends its answer to the owner, +// so the call is fire and forget and returns the zero value. +func (c *Client) NumericArrayReturn(ctx context.Context) ([]float64, error) { + if c.sendOnly { + return nil, c.conn.SendRPC(FnNumericArrayReturn) + } + payload, err := c.conn.Call(ctx, FnNumericArrayReturn) + if err != nil { + return nil, err + } + return readFloat64Slice(payload, 4) +} + // SetRegister1 writes the register 0 as a configuration transaction. func (c *Client) SetRegister1(Register1 string) error { return c.conn.SendTransaction(true, xbot.Data{ID: 0, Data: charParam(Register1, 42)}) @@ -180,3 +246,32 @@ func charValue(b []byte) string { func errf(format string, args ...any) error { return fmt.Errorf("servicetemplate: "+format, args...) } + +func padFloat64Slice(v []float64, n int) []float64 { + if n <= 0 || len(v) >= n { + return v + } + out := make([]float64, n) + copy(out, v) + return out +} + +func leFloat64Slice(v []float64, n int) []byte { + v = padFloat64Slice(v, n) + b := make([]byte, len(v)*8) + for i, x := range v { + binary.LittleEndian.PutUint64(b[i*8:], math.Float64bits(x)) + } + return b +} + +func readFloat64Slice(b []byte, n int) ([]float64, error) { + if n <= 0 || len(b) < n*8 { + return nil, errf("array of 8 elements: got %d bytes, want %d", len(b), n*8) + } + out := make([]float64, n) + for i := range out { + out[i] = math.Float64frombits(binary.LittleEndian.Uint64(b[i*8:])) + } + return out, nil +} diff --git a/xbot_service_interface_go/codegen/testdata/demo_service.json b/xbot_service_interface_go/codegen/testdata/demo_service.json index 23f406e..45091fa 100644 --- a/xbot_service_interface_go/codegen/testdata/demo_service.json +++ b/xbot_service_interface_go/codegen/testdata/demo_service.json @@ -77,6 +77,12 @@ } ], "return_type": "uint8_t" + }, + { + "id": 4, + "name": "NumericArrayReturn", + "parameters": [], + "return_type": "double[4]" } ], "inputs": [ 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 7f7821a..8ad63ff 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 @@ -22,6 +22,7 @@ const ( FnScalarParamsWithReturn uint8 = 1 FnArrayParamNoReturn uint8 = 2 FnMixedParamsWithReturn uint8 = 3 + FnNumericArrayReturn uint8 = 4 ) // ExampleEnumClass values (service.json -> "enums"). @@ -43,7 +44,13 @@ const ( ) // Client is a client for the ServiceTemplate service. -type Client struct{ conn *xbot.Conn } +type Client struct { + conn *xbot.Conn + // sendOnly is set when this client does not own the service (NoClaim or + // Endpoint): the service sends its RPC answers to the owner, so the calls + // are fire and forget. + sendOnly bool +} // 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) { @@ -54,7 +61,7 @@ func Dial(ctx context.Context, serviceID uint16, opts xbot.Options) (*Client, er if err != nil { return nil, err } - return &Client{conn: conn}, nil + return &Client{conn: conn, sendOnly: opts.NoClaim || opts.Endpoint != ""}, nil } // Close closes the connection. @@ -64,13 +71,31 @@ func (c *Client) Close() error { return c.conn.Close() } func (c *Client) Endpoint() string { return c.conn.Endpoint() } // NoParamsNoReturn calls the RPC function 0. +// +// It only waits for the answer when this client owns the service. Without a claim +// (Options.NoClaim or Options.Endpoint) the service sends its answer to the owner, +// so the call is fire and forget and returns the zero value. func (c *Client) NoParamsNoReturn(ctx context.Context) error { + if c.sendOnly { + return c.conn.SendRPC(FnNoParamsNoReturn) + } _, err := c.conn.Call(ctx, FnNoParamsNoReturn) return err } // ScalarParamsWithReturn calls the RPC function 1. +// +// It only waits for the answer when this client owns the service. Without a claim +// (Options.NoClaim or Options.Endpoint) the service sends its answer to the owner, +// so the call is fire and forget and returns the zero value. func (c *Client) ScalarParamsWithReturn(ctx context.Context, Speed float32, Count uint32, Enable uint8) (int32, error) { + if c.sendOnly { + return 0, c.conn.SendRPC(FnScalarParamsWithReturn, + xbot.Bytes(0, leFloat32(Speed)), + xbot.Bytes(1, leUint32(Count)), + xbot.Bytes(2, leUint8(Enable)), + ) + } payload, err := c.conn.Call(ctx, FnScalarParamsWithReturn, xbot.Bytes(0, leFloat32(Speed)), xbot.Bytes(1, leUint32(Count)), @@ -86,7 +111,19 @@ func (c *Client) ScalarParamsWithReturn(ctx context.Context, Speed float32, Coun } // ArrayParamNoReturn calls the RPC function 2. +// +// It only waits for the answer when this client owns the service. Without a claim +// (Options.NoClaim or Options.Endpoint) the service sends its answer to the owner, +// so the call is fire and forget and returns the zero value. func (c *Client) ArrayParamNoReturn(ctx context.Context, Label string) error { + if len(Label) >= 64 { // char[64] includes the NUL + return errf("Label too long (%d bytes, max %d)", len(Label), 63) + } + if c.sendOnly { + return c.conn.SendRPC(FnArrayParamNoReturn, + xbot.Bytes(0, charParam(Label, 64)), + ) + } _, err := c.conn.Call(ctx, FnArrayParamNoReturn, xbot.Bytes(0, charParam(Label, 64)), ) @@ -94,7 +131,20 @@ func (c *Client) ArrayParamNoReturn(ctx context.Context, Label string) error { } // MixedParamsWithReturn calls the RPC function 3. +// +// It only waits for the answer when this client owns the service. Without a claim +// (Options.NoClaim or Options.Endpoint) the service sends its answer to the owner, +// so the call is fire and forget and returns the zero value. func (c *Client) MixedParamsWithReturn(ctx context.Context, Name string, Value float32) (uint8, error) { + if len(Name) >= 32 { // char[32] includes the NUL + return 0, errf("Name too long (%d bytes, max %d)", len(Name), 31) + } + if c.sendOnly { + return 0, c.conn.SendRPC(FnMixedParamsWithReturn, + xbot.Bytes(0, charParam(Name, 32)), + xbot.Bytes(1, leFloat32(Value)), + ) + } payload, err := c.conn.Call(ctx, FnMixedParamsWithReturn, xbot.Bytes(0, charParam(Name, 32)), xbot.Bytes(1, leFloat32(Value)), @@ -108,6 +158,22 @@ func (c *Client) MixedParamsWithReturn(ctx context.Context, Name string, Value f return payload[0], nil } +// NumericArrayReturn calls the RPC function 4. +// +// It only waits for the answer when this client owns the service. Without a claim +// (Options.NoClaim or Options.Endpoint) the service sends its answer to the owner, +// so the call is fire and forget and returns the zero value. +func (c *Client) NumericArrayReturn(ctx context.Context) ([]float64, error) { + if c.sendOnly { + return nil, c.conn.SendRPC(FnNumericArrayReturn) + } + payload, err := c.conn.Call(ctx, FnNumericArrayReturn) + if err != nil { + return nil, err + } + return readFloat64Slice(payload, 4) +} + // SetRegister1 writes the register 0 as a configuration transaction. func (c *Client) SetRegister1(Register1 string) error { return c.conn.SendTransaction(true, xbot.Data{ID: 0, Data: charParam(Register1, 42)}) @@ -177,3 +243,32 @@ func charValue(b []byte) string { func errf(format string, args ...any) error { return fmt.Errorf("servicetemplate: "+format, args...) } + +func padFloat64Slice(v []float64, n int) []float64 { + if n <= 0 || len(v) >= n { + return v + } + out := make([]float64, n) + copy(out, v) + return out +} + +func leFloat64Slice(v []float64, n int) []byte { + v = padFloat64Slice(v, n) + b := make([]byte, len(v)*8) + for i, x := range v { + binary.LittleEndian.PutUint64(b[i*8:], math.Float64bits(x)) + } + return b +} + +func readFloat64Slice(b []byte, n int) ([]float64, error) { + if n <= 0 || len(b) < n*8 { + return nil, errf("array of 8 elements: got %d bytes, want %d", len(b), n*8) + } + out := make([]float64, n) + for i := range out { + out[i] = math.Float64frombits(binary.LittleEndian.Uint64(b[i*8:])) + } + return out, nil +}