From 52ed5a7e3aa76c3fc971c52b8b21c25883be980b Mon Sep 17 00:00:00 2001 From: Ryan D McGuire Date: Thu, 16 Jul 2026 15:35:12 -0400 Subject: [PATCH] add set mode option --- AGENTS.md | 25 +- api/econet/v1alpha1/econet.pb.go | 241 ++++++++++++++++-- api/econet/v1alpha1/econet.pb.gw.go | 84 ++++++ api/econet/v1alpha1/econet_grpc.pb.go | 38 +++ .../v1alpha1/v1alpha1mcp/econet.pb.mcp.go | 130 ++++++++++ contrib/econet-exporter.swagger.json | 71 ++++++ pkg/econet/econetclient/client.go | 58 +++++ pkg/econet/econetclient/device.go | 27 ++ pkg/econet/econetgrpc/econet.go | 46 ++++ proto/econet/v1alpha1/econet.proto | 36 +++ 10 files changed, 731 insertions(+), 25 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 377b3a9..242481b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,14 +16,27 @@ Exporter for Rheem EcoNet / Rheemcloud water heaters, built on the HTTP/gRPC server startup. The client is stateless REST, so `ShutdownFunc` is effectively a no-op (the poll goroutine stops on ctx cancel). - `pkg/econet/econetclient/` — a minimal EcoNet REST client (ClearBlade - backend). `client.go` does the 3 REST calls (auth → `getUserDataForApp` → + backend). `client.go` does the read REST calls (auth → `getUserDataForApp` → `dynamicAction` usage) and caches a snapshot under an `RWMutex`; `device.go` decodes the `@...` datapoints into a flat `Device` struct (ported from kevinburke/rheemcloud-go, minus MQTT). `Devices()` is empty until the first `Refresh` succeeds — consumers just see an empty list (no nil-guarding). **No MQTT**, so `WiFiSignal` and `Running`/`RunningState` stay zero. +- **Writes (SetMode)**: `Client.SetMode` changes a unit's operating mode by + publishing a desired-state message over ClearBlade's REST publish endpoint + (`POST /message/{systemKey}/publish`, topic `user/{account_id}/device/desired`, + payload `{transactionId, device_name, serial_number, "@MODE": }`). This + is the HTTP equivalent of the app's MQTT publish — **no MQTT client needed**. + `@MODE` is an *index* into the device's reported mode list, so `device.go` + retains that list (`modeEnumText`) and maps a slug → index (`modeIndex`); + supported modes are device-specific (`Device.SupportedModes`). The change is + **applied asynchronously** — the new mode only shows up on a later `Refresh`, + and a 200 from publish means "accepted", not "applied". - `pkg/econet/econetgrpc/` — `EconetService` gRPC impl (`ListDevices`, - `GetDevice`) + `deviceToProto`. Does **not** own a client; it's injected. + `GetDevice`, `SetMode`) + `deviceToProto`. Does **not** own a client; it's + injected. `SetMode` maps the proto `Mode` enum → client slug; note MCP calls + it in-process and so **bypass the protovalidate interceptor** (the gRPC/REST + paths don't), hence `SetMode` re-checks the enum itself. - `pkg/econet/econetmcpgen/` — **active** MCP server at `/api/mcp`. Registers the tools generated by protoc-gen-go-mcp (`api/econet/v1alpha1/v1alpha1mcp/`) against our go-sdk `*mcp.Server` via the `runtime/gosdk` adapter, forwarding @@ -90,3 +103,11 @@ Creds are configured via `ECONET_EMAIL` / `ECONET_PASSWORD`. Run (streamable HTTP; POST to `/api/mcp/` with trailing slash — bare `/api/mcp` 307-redirects). The REST gateway needs `grpcGatewayPathStrip: true` in config.yaml (otherwise the `/api` prefix isn't stripped and routes 404). + +`SetMode` (write) can be driven three ways once a real device is loaded: +`grpcurl -plaintext -d '{"serial_number":"","mode":"MODE_HEAT_PUMP"}' :8081 econet.v1alpha1.EconetService/SetMode`, +`POST /api/v1alpha1/devices//mode` with `{"mode":"MODE_HEAT_PUMP"}`, or the +MCP `econet_v1alpha1_EconetService_SetMode` tool. **Unverified against a live +account**: whether the user token is authorized to publish over REST (same +authority as MQTT, but Rheem's topic ACL is untested) — this is the one thing +that can't be confirmed without a real unit. diff --git a/api/econet/v1alpha1/econet.pb.go b/api/econet/v1alpha1/econet.pb.go index 0a26c9b..591d17d 100644 --- a/api/econet/v1alpha1/econet.pb.go +++ b/api/econet/v1alpha1/econet.pb.go @@ -24,6 +24,77 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +// Mode is a settable operating mode for a water heater. The values mirror the +// kebab-case slugs reported in Device.mode. Not every device supports every +// mode; the supported set is device-specific (derived from the unit's reported +// mode list), so SetMode validates against the target device. +type Mode int32 + +const ( + Mode_MODE_UNSPECIFIED Mode = 0 + Mode_MODE_OFF Mode = 1 + Mode_MODE_ELECTRIC Mode = 2 + Mode_MODE_ENERGY_SAVING Mode = 3 + Mode_MODE_HEAT_PUMP Mode = 4 + Mode_MODE_HIGH_DEMAND Mode = 5 + Mode_MODE_GAS Mode = 6 + Mode_MODE_PERFORMANCE Mode = 7 + Mode_MODE_VACATION Mode = 8 +) + +// Enum value maps for Mode. +var ( + Mode_name = map[int32]string{ + 0: "MODE_UNSPECIFIED", + 1: "MODE_OFF", + 2: "MODE_ELECTRIC", + 3: "MODE_ENERGY_SAVING", + 4: "MODE_HEAT_PUMP", + 5: "MODE_HIGH_DEMAND", + 6: "MODE_GAS", + 7: "MODE_PERFORMANCE", + 8: "MODE_VACATION", + } + Mode_value = map[string]int32{ + "MODE_UNSPECIFIED": 0, + "MODE_OFF": 1, + "MODE_ELECTRIC": 2, + "MODE_ENERGY_SAVING": 3, + "MODE_HEAT_PUMP": 4, + "MODE_HIGH_DEMAND": 5, + "MODE_GAS": 6, + "MODE_PERFORMANCE": 7, + "MODE_VACATION": 8, + } +) + +func (x Mode) Enum() *Mode { + p := new(Mode) + *p = x + return p +} + +func (x Mode) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Mode) Descriptor() protoreflect.EnumDescriptor { + return file_econet_v1alpha1_econet_proto_enumTypes[0].Descriptor() +} + +func (Mode) Type() protoreflect.EnumType { + return &file_econet_v1alpha1_econet_proto_enumTypes[0] +} + +func (x Mode) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Mode.Descriptor instead. +func (Mode) EnumDescriptor() ([]byte, []int) { + return file_econet_v1alpha1_econet_proto_rawDescGZIP(), []int{0} +} + // Device is the live state of a single Rheem EcoNet device // (typically a water heater) as reported by the Rheem cloud. type Device struct { @@ -374,6 +445,104 @@ func (x *GetDeviceResponse) GetDevice() *Device { return nil } +type SetModeRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SerialNumber string `protobuf:"bytes,1,opt,name=serial_number,json=serialNumber,proto3" json:"serial_number,omitempty"` + Mode Mode `protobuf:"varint,2,opt,name=mode,proto3,enum=econet.v1alpha1.Mode" json:"mode,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetModeRequest) Reset() { + *x = SetModeRequest{} + mi := &file_econet_v1alpha1_econet_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetModeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetModeRequest) ProtoMessage() {} + +func (x *SetModeRequest) ProtoReflect() protoreflect.Message { + mi := &file_econet_v1alpha1_econet_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetModeRequest.ProtoReflect.Descriptor instead. +func (*SetModeRequest) Descriptor() ([]byte, []int) { + return file_econet_v1alpha1_econet_proto_rawDescGZIP(), []int{5} +} + +func (x *SetModeRequest) GetSerialNumber() string { + if x != nil { + return x.SerialNumber + } + return "" +} + +func (x *SetModeRequest) GetMode() Mode { + if x != nil { + return x.Mode + } + return Mode_MODE_UNSPECIFIED +} + +type SetModeResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Last-known device state. The mode change is applied asynchronously by the + // Rheem cloud, so this reflects the requested mode only after a later poll. + Device *Device `protobuf:"bytes,1,opt,name=device,proto3" json:"device,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetModeResponse) Reset() { + *x = SetModeResponse{} + mi := &file_econet_v1alpha1_econet_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetModeResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetModeResponse) ProtoMessage() {} + +func (x *SetModeResponse) ProtoReflect() protoreflect.Message { + mi := &file_econet_v1alpha1_econet_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetModeResponse.ProtoReflect.Descriptor instead. +func (*SetModeResponse) Descriptor() ([]byte, []int) { + return file_econet_v1alpha1_econet_proto_rawDescGZIP(), []int{6} +} + +func (x *SetModeResponse) GetDevice() *Device { + if x != nil { + return x.Device + } + return nil +} + var File_econet_v1alpha1_econet_proto protoreflect.FileDescriptor const file_econet_v1alpha1_econet_proto_rawDesc = "" + @@ -407,10 +576,27 @@ const file_econet_v1alpha1_econet_proto_rawDesc = "" + "\x10GetDeviceRequest\x12,\n" + "\rserial_number\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\fserialNumber\"D\n" + "\x11GetDeviceResponse\x12/\n" + - "\x06device\x18\x01 \x01(\v2\x17.econet.v1alpha1.DeviceR\x06device2\x83\x02\n" + + "\x06device\x18\x01 \x01(\v2\x17.econet.v1alpha1.DeviceR\x06device\"u\n" + + "\x0eSetModeRequest\x12,\n" + + "\rserial_number\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\fserialNumber\x125\n" + + "\x04mode\x18\x02 \x01(\x0e2\x15.econet.v1alpha1.ModeB\n" + + "\xbaH\a\x82\x01\x04\x10\x01 \x00R\x04mode\"B\n" + + "\x0fSetModeResponse\x12/\n" + + "\x06device\x18\x01 \x01(\v2\x17.econet.v1alpha1.DeviceR\x06device*\xb6\x01\n" + + "\x04Mode\x12\x14\n" + + "\x10MODE_UNSPECIFIED\x10\x00\x12\f\n" + + "\bMODE_OFF\x10\x01\x12\x11\n" + + "\rMODE_ELECTRIC\x10\x02\x12\x16\n" + + "\x12MODE_ENERGY_SAVING\x10\x03\x12\x12\n" + + "\x0eMODE_HEAT_PUMP\x10\x04\x12\x14\n" + + "\x10MODE_HIGH_DEMAND\x10\x05\x12\f\n" + + "\bMODE_GAS\x10\x06\x12\x14\n" + + "\x10MODE_PERFORMANCE\x10\a\x12\x11\n" + + "\rMODE_VACATION\x10\b2\x84\x03\n" + "\rEconetService\x12s\n" + "\vListDevices\x12#.econet.v1alpha1.ListDevicesRequest\x1a$.econet.v1alpha1.ListDevicesResponse\"\x19\x82\xd3\xe4\x93\x02\x13\x12\x11/v1alpha1/devices\x12}\n" + - "\tGetDevice\x12!.econet.v1alpha1.GetDeviceRequest\x1a\".econet.v1alpha1.GetDeviceResponse\")\x82\xd3\xe4\x93\x02#\x12!/v1alpha1/devices/{serial_number}B\xcb\x01\n" + + "\tGetDevice\x12!.econet.v1alpha1.GetDeviceRequest\x1a\".econet.v1alpha1.GetDeviceResponse\")\x82\xd3\xe4\x93\x02#\x12!/v1alpha1/devices/{serial_number}\x12\x7f\n" + + "\aSetMode\x12\x1f.econet.v1alpha1.SetModeRequest\x1a .econet.v1alpha1.SetModeResponse\"1\x82\xd3\xe4\x93\x02+:\x01*\"&/v1alpha1/devices/{serial_number}/modeB\xcb\x01\n" + "\x13com.econet.v1alpha1B\vEconetProtoP\x01ZJgitea.libretechconsulting.com/rmcguire/econet-exporter/api/econet/v1alpha1\xa2\x02\x03EXX\xaa\x02\x0fEconet.V1alpha1\xca\x02\x0fEconet\\V1alpha1\xe2\x02\x1bEconet\\V1alpha1\\GPBMetadata\xea\x02\x10Econet::V1alpha1b\x06proto3" var ( @@ -425,28 +611,36 @@ func file_econet_v1alpha1_econet_proto_rawDescGZIP() []byte { return file_econet_v1alpha1_econet_proto_rawDescData } -var file_econet_v1alpha1_econet_proto_msgTypes = make([]protoimpl.MessageInfo, 5) +var file_econet_v1alpha1_econet_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_econet_v1alpha1_econet_proto_msgTypes = make([]protoimpl.MessageInfo, 7) var file_econet_v1alpha1_econet_proto_goTypes = []any{ - (*Device)(nil), // 0: econet.v1alpha1.Device - (*ListDevicesRequest)(nil), // 1: econet.v1alpha1.ListDevicesRequest - (*ListDevicesResponse)(nil), // 2: econet.v1alpha1.ListDevicesResponse - (*GetDeviceRequest)(nil), // 3: econet.v1alpha1.GetDeviceRequest - (*GetDeviceResponse)(nil), // 4: econet.v1alpha1.GetDeviceResponse - (*timestamppb.Timestamp)(nil), // 5: google.protobuf.Timestamp + (Mode)(0), // 0: econet.v1alpha1.Mode + (*Device)(nil), // 1: econet.v1alpha1.Device + (*ListDevicesRequest)(nil), // 2: econet.v1alpha1.ListDevicesRequest + (*ListDevicesResponse)(nil), // 3: econet.v1alpha1.ListDevicesResponse + (*GetDeviceRequest)(nil), // 4: econet.v1alpha1.GetDeviceRequest + (*GetDeviceResponse)(nil), // 5: econet.v1alpha1.GetDeviceResponse + (*SetModeRequest)(nil), // 6: econet.v1alpha1.SetModeRequest + (*SetModeResponse)(nil), // 7: econet.v1alpha1.SetModeResponse + (*timestamppb.Timestamp)(nil), // 8: google.protobuf.Timestamp } var file_econet_v1alpha1_econet_proto_depIdxs = []int32{ - 5, // 0: econet.v1alpha1.Device.last_updated:type_name -> google.protobuf.Timestamp - 0, // 1: econet.v1alpha1.ListDevicesResponse.devices:type_name -> econet.v1alpha1.Device - 0, // 2: econet.v1alpha1.GetDeviceResponse.device:type_name -> econet.v1alpha1.Device - 1, // 3: econet.v1alpha1.EconetService.ListDevices:input_type -> econet.v1alpha1.ListDevicesRequest - 3, // 4: econet.v1alpha1.EconetService.GetDevice:input_type -> econet.v1alpha1.GetDeviceRequest - 2, // 5: econet.v1alpha1.EconetService.ListDevices:output_type -> econet.v1alpha1.ListDevicesResponse - 4, // 6: econet.v1alpha1.EconetService.GetDevice:output_type -> econet.v1alpha1.GetDeviceResponse - 5, // [5:7] is the sub-list for method output_type - 3, // [3:5] is the sub-list for method input_type - 3, // [3:3] is the sub-list for extension type_name - 3, // [3:3] is the sub-list for extension extendee - 0, // [0:3] is the sub-list for field type_name + 8, // 0: econet.v1alpha1.Device.last_updated:type_name -> google.protobuf.Timestamp + 1, // 1: econet.v1alpha1.ListDevicesResponse.devices:type_name -> econet.v1alpha1.Device + 1, // 2: econet.v1alpha1.GetDeviceResponse.device:type_name -> econet.v1alpha1.Device + 0, // 3: econet.v1alpha1.SetModeRequest.mode:type_name -> econet.v1alpha1.Mode + 1, // 4: econet.v1alpha1.SetModeResponse.device:type_name -> econet.v1alpha1.Device + 2, // 5: econet.v1alpha1.EconetService.ListDevices:input_type -> econet.v1alpha1.ListDevicesRequest + 4, // 6: econet.v1alpha1.EconetService.GetDevice:input_type -> econet.v1alpha1.GetDeviceRequest + 6, // 7: econet.v1alpha1.EconetService.SetMode:input_type -> econet.v1alpha1.SetModeRequest + 3, // 8: econet.v1alpha1.EconetService.ListDevices:output_type -> econet.v1alpha1.ListDevicesResponse + 5, // 9: econet.v1alpha1.EconetService.GetDevice:output_type -> econet.v1alpha1.GetDeviceResponse + 7, // 10: econet.v1alpha1.EconetService.SetMode:output_type -> econet.v1alpha1.SetModeResponse + 8, // [8:11] is the sub-list for method output_type + 5, // [5:8] is the sub-list for method input_type + 5, // [5:5] is the sub-list for extension type_name + 5, // [5:5] is the sub-list for extension extendee + 0, // [0:5] is the sub-list for field type_name } func init() { file_econet_v1alpha1_econet_proto_init() } @@ -459,13 +653,14 @@ func file_econet_v1alpha1_econet_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_econet_v1alpha1_econet_proto_rawDesc), len(file_econet_v1alpha1_econet_proto_rawDesc)), - NumEnums: 0, - NumMessages: 5, + NumEnums: 1, + NumMessages: 7, NumExtensions: 0, NumServices: 1, }, GoTypes: file_econet_v1alpha1_econet_proto_goTypes, DependencyIndexes: file_econet_v1alpha1_econet_proto_depIdxs, + EnumInfos: file_econet_v1alpha1_econet_proto_enumTypes, MessageInfos: file_econet_v1alpha1_econet_proto_msgTypes, }.Build() File_econet_v1alpha1_econet_proto = out.File diff --git a/api/econet/v1alpha1/econet.pb.gw.go b/api/econet/v1alpha1/econet.pb.gw.go index fb1b6de..a566e76 100644 --- a/api/econet/v1alpha1/econet.pb.gw.go +++ b/api/econet/v1alpha1/econet.pb.gw.go @@ -95,6 +95,51 @@ func local_request_EconetService_GetDevice_0(ctx context.Context, marshaler runt return msg, metadata, err } +func request_EconetService_SetMode_0(ctx context.Context, marshaler runtime.Marshaler, client EconetServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SetModeRequest + metadata runtime.ServerMetadata + err error + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + } + val, ok := pathParams["serial_number"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "serial_number") + } + protoReq.SerialNumber, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "serial_number", err) + } + msg, err := client.SetMode(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err +} + +func local_request_EconetService_SetMode_0(ctx context.Context, marshaler runtime.Marshaler, server EconetServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var ( + protoReq SetModeRequest + metadata runtime.ServerMetadata + err error + ) + if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + val, ok := pathParams["serial_number"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "serial_number") + } + protoReq.SerialNumber, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "serial_number", err) + } + msg, err := server.SetMode(ctx, &protoReq) + return msg, metadata, err +} + // RegisterEconetServiceHandlerServer registers the http handlers for service EconetService to "mux". // UnaryRPC :call EconetServiceServer directly. // StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906. @@ -141,6 +186,26 @@ func RegisterEconetServiceHandlerServer(ctx context.Context, mux *runtime.ServeM } forward_EconetService_GetDevice_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) }) + mux.Handle(http.MethodPost, pattern_EconetService_SetMode_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/econet.v1alpha1.EconetService/SetMode", runtime.WithHTTPPathPattern("/v1alpha1/devices/{serial_number}/mode")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_EconetService_SetMode_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_EconetService_SetMode_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) return nil } @@ -215,15 +280,34 @@ func RegisterEconetServiceHandlerClient(ctx context.Context, mux *runtime.ServeM } forward_EconetService_GetDevice_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) }) + mux.Handle(http.MethodPost, pattern_EconetService_SetMode_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/econet.v1alpha1.EconetService/SetMode", runtime.WithHTTPPathPattern("/v1alpha1/devices/{serial_number}/mode")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_EconetService_SetMode_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + forward_EconetService_SetMode_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + }) return nil } var ( pattern_EconetService_ListDevices_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"v1alpha1", "devices"}, "")) pattern_EconetService_GetDevice_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"v1alpha1", "devices", "serial_number"}, "")) + pattern_EconetService_SetMode_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"v1alpha1", "devices", "serial_number", "mode"}, "")) ) var ( forward_EconetService_ListDevices_0 = runtime.ForwardResponseMessage forward_EconetService_GetDevice_0 = runtime.ForwardResponseMessage + forward_EconetService_SetMode_0 = runtime.ForwardResponseMessage ) diff --git a/api/econet/v1alpha1/econet_grpc.pb.go b/api/econet/v1alpha1/econet_grpc.pb.go index 377def2..79d56b5 100644 --- a/api/econet/v1alpha1/econet_grpc.pb.go +++ b/api/econet/v1alpha1/econet_grpc.pb.go @@ -21,6 +21,7 @@ const _ = grpc.SupportPackageIsVersion9 const ( EconetService_ListDevices_FullMethodName = "/econet.v1alpha1.EconetService/ListDevices" EconetService_GetDevice_FullMethodName = "/econet.v1alpha1.EconetService/GetDevice" + EconetService_SetMode_FullMethodName = "/econet.v1alpha1.EconetService/SetMode" ) // EconetServiceClient is the client API for EconetService service. @@ -31,6 +32,7 @@ const ( type EconetServiceClient interface { ListDevices(ctx context.Context, in *ListDevicesRequest, opts ...grpc.CallOption) (*ListDevicesResponse, error) GetDevice(ctx context.Context, in *GetDeviceRequest, opts ...grpc.CallOption) (*GetDeviceResponse, error) + SetMode(ctx context.Context, in *SetModeRequest, opts ...grpc.CallOption) (*SetModeResponse, error) } type econetServiceClient struct { @@ -61,6 +63,16 @@ func (c *econetServiceClient) GetDevice(ctx context.Context, in *GetDeviceReques return out, nil } +func (c *econetServiceClient) SetMode(ctx context.Context, in *SetModeRequest, opts ...grpc.CallOption) (*SetModeResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SetModeResponse) + err := c.cc.Invoke(ctx, EconetService_SetMode_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // EconetServiceServer is the server API for EconetService service. // All implementations should embed UnimplementedEconetServiceServer // for forward compatibility. @@ -69,6 +81,7 @@ func (c *econetServiceClient) GetDevice(ctx context.Context, in *GetDeviceReques type EconetServiceServer interface { ListDevices(context.Context, *ListDevicesRequest) (*ListDevicesResponse, error) GetDevice(context.Context, *GetDeviceRequest) (*GetDeviceResponse, error) + SetMode(context.Context, *SetModeRequest) (*SetModeResponse, error) } // UnimplementedEconetServiceServer should be embedded to have @@ -84,6 +97,9 @@ func (UnimplementedEconetServiceServer) ListDevices(context.Context, *ListDevice func (UnimplementedEconetServiceServer) GetDevice(context.Context, *GetDeviceRequest) (*GetDeviceResponse, error) { return nil, status.Error(codes.Unimplemented, "method GetDevice not implemented") } +func (UnimplementedEconetServiceServer) SetMode(context.Context, *SetModeRequest) (*SetModeResponse, error) { + return nil, status.Error(codes.Unimplemented, "method SetMode not implemented") +} func (UnimplementedEconetServiceServer) testEmbeddedByValue() {} // UnsafeEconetServiceServer may be embedded to opt out of forward compatibility for this service. @@ -140,6 +156,24 @@ func _EconetService_GetDevice_Handler(srv interface{}, ctx context.Context, dec return interceptor(ctx, in, info, handler) } +func _EconetService_SetMode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SetModeRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(EconetServiceServer).SetMode(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: EconetService_SetMode_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(EconetServiceServer).SetMode(ctx, req.(*SetModeRequest)) + } + return interceptor(ctx, in, info, handler) +} + // EconetService_ServiceDesc is the grpc.ServiceDesc for EconetService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -155,6 +189,10 @@ var EconetService_ServiceDesc = grpc.ServiceDesc{ MethodName: "GetDevice", Handler: _EconetService_GetDevice_Handler, }, + { + MethodName: "SetMode", + Handler: _EconetService_SetMode_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "econet/v1alpha1/econet.proto", diff --git a/api/econet/v1alpha1/v1alpha1mcp/econet.pb.mcp.go b/api/econet/v1alpha1/v1alpha1mcp/econet.pb.mcp.go index 6421eeb..3ed4df3 100644 --- a/api/econet/v1alpha1/v1alpha1mcp/econet.pb.mcp.go +++ b/api/econet/v1alpha1/v1alpha1mcp/econet.pb.mcp.go @@ -19,12 +19,14 @@ import ( var ( EconetService_GetDeviceTool = runtime.Tool{Name: "econet_v1alpha1_EconetService_GetDevice", Description: "", RawInputSchema: json.RawMessage{0x7b, 0x22, 0x70, 0x72, 0x6f, 0x70, 0x65, 0x72, 0x74, 0x69, 0x65, 0x73, 0x22, 0x3a, 0x7b, 0x22, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x22, 0x3a, 0x7b, 0x22, 0x6d, 0x69, 0x6e, 0x4c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x22, 0x3a, 0x31, 0x2c, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x7d, 0x2c, 0x22, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x22, 0x3a, 0x5b, 0x5d, 0x2c, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x22, 0x7d}, RawOutputSchema: json.RawMessage{0x7b, 0x22, 0x70, 0x72, 0x6f, 0x70, 0x65, 0x72, 0x74, 0x69, 0x65, 0x73, 0x22, 0x3a, 0x7b, 0x22, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x22, 0x3a, 0x7b, 0x22, 0x70, 0x72, 0x6f, 0x70, 0x65, 0x72, 0x74, 0x69, 0x65, 0x73, 0x22, 0x3a, 0x7b, 0x22, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x65, 0x72, 0x22, 0x7d, 0x2c, 0x22, 0x61, 0x77, 0x61, 0x79, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x62, 0x6f, 0x6f, 0x6c, 0x65, 0x61, 0x6e, 0x22, 0x7d, 0x2c, 0x22, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x65, 0x64, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x62, 0x6f, 0x6f, 0x6c, 0x65, 0x61, 0x6e, 0x22, 0x7d, 0x2c, 0x22, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x2c, 0x22, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x62, 0x6f, 0x6f, 0x6c, 0x65, 0x61, 0x6e, 0x22, 0x7d, 0x2c, 0x22, 0x66, 0x72, 0x69, 0x65, 0x6e, 0x64, 0x6c, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x2c, 0x22, 0x67, 0x65, 0x6e, 0x65, 0x72, 0x69, 0x63, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x2c, 0x22, 0x68, 0x6f, 0x74, 0x5f, 0x77, 0x61, 0x74, 0x65, 0x72, 0x5f, 0x61, 0x76, 0x61, 0x69, 0x6c, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x65, 0x72, 0x22, 0x7d, 0x2c, 0x22, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x22, 0x3a, 0x7b, 0x22, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x22, 0x3a, 0x22, 0x64, 0x61, 0x74, 0x65, 0x2d, 0x74, 0x69, 0x6d, 0x65, 0x22, 0x2c, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x5b, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x2c, 0x22, 0x6e, 0x75, 0x6c, 0x6c, 0x22, 0x5d, 0x7d, 0x2c, 0x22, 0x6d, 0x6f, 0x64, 0x65, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x2c, 0x22, 0x72, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x62, 0x6f, 0x6f, 0x6c, 0x65, 0x61, 0x6e, 0x22, 0x7d, 0x2c, 0x22, 0x72, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x65, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x2c, 0x22, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x2c, 0x22, 0x73, 0x65, 0x74, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x65, 0x72, 0x22, 0x7d, 0x2c, 0x22, 0x73, 0x65, 0x74, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x5f, 0x6d, 0x61, 0x78, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x65, 0x72, 0x22, 0x7d, 0x2c, 0x22, 0x73, 0x65, 0x74, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x5f, 0x6d, 0x69, 0x6e, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x65, 0x72, 0x22, 0x7d, 0x2c, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x2c, 0x22, 0x77, 0x69, 0x66, 0x69, 0x5f, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x65, 0x72, 0x22, 0x7d, 0x7d, 0x2c, 0x22, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x22, 0x3a, 0x5b, 0x5d, 0x2c, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x22, 0x7d, 0x7d, 0x2c, 0x22, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x22, 0x3a, 0x5b, 0x5d, 0x2c, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x22, 0x7d}} EconetService_ListDevicesTool = runtime.Tool{Name: "econet_v1alpha1_EconetService_ListDevices", Description: "", RawInputSchema: json.RawMessage{0x7b, 0x22, 0x70, 0x72, 0x6f, 0x70, 0x65, 0x72, 0x74, 0x69, 0x65, 0x73, 0x22, 0x3a, 0x7b, 0x7d, 0x2c, 0x22, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x22, 0x3a, 0x5b, 0x5d, 0x2c, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x22, 0x7d}, RawOutputSchema: json.RawMessage{0x7b, 0x22, 0x70, 0x72, 0x6f, 0x70, 0x65, 0x72, 0x74, 0x69, 0x65, 0x73, 0x22, 0x3a, 0x7b, 0x22, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x73, 0x22, 0x3a, 0x7b, 0x22, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x22, 0x3a, 0x7b, 0x22, 0x70, 0x72, 0x6f, 0x70, 0x65, 0x72, 0x74, 0x69, 0x65, 0x73, 0x22, 0x3a, 0x7b, 0x22, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x65, 0x72, 0x22, 0x7d, 0x2c, 0x22, 0x61, 0x77, 0x61, 0x79, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x62, 0x6f, 0x6f, 0x6c, 0x65, 0x61, 0x6e, 0x22, 0x7d, 0x2c, 0x22, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x65, 0x64, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x62, 0x6f, 0x6f, 0x6c, 0x65, 0x61, 0x6e, 0x22, 0x7d, 0x2c, 0x22, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x2c, 0x22, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x62, 0x6f, 0x6f, 0x6c, 0x65, 0x61, 0x6e, 0x22, 0x7d, 0x2c, 0x22, 0x66, 0x72, 0x69, 0x65, 0x6e, 0x64, 0x6c, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x2c, 0x22, 0x67, 0x65, 0x6e, 0x65, 0x72, 0x69, 0x63, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x2c, 0x22, 0x68, 0x6f, 0x74, 0x5f, 0x77, 0x61, 0x74, 0x65, 0x72, 0x5f, 0x61, 0x76, 0x61, 0x69, 0x6c, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x65, 0x72, 0x22, 0x7d, 0x2c, 0x22, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x22, 0x3a, 0x7b, 0x22, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x22, 0x3a, 0x22, 0x64, 0x61, 0x74, 0x65, 0x2d, 0x74, 0x69, 0x6d, 0x65, 0x22, 0x2c, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x5b, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x2c, 0x22, 0x6e, 0x75, 0x6c, 0x6c, 0x22, 0x5d, 0x7d, 0x2c, 0x22, 0x6d, 0x6f, 0x64, 0x65, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x2c, 0x22, 0x72, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x62, 0x6f, 0x6f, 0x6c, 0x65, 0x61, 0x6e, 0x22, 0x7d, 0x2c, 0x22, 0x72, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x65, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x2c, 0x22, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x2c, 0x22, 0x73, 0x65, 0x74, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x65, 0x72, 0x22, 0x7d, 0x2c, 0x22, 0x73, 0x65, 0x74, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x5f, 0x6d, 0x61, 0x78, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x65, 0x72, 0x22, 0x7d, 0x2c, 0x22, 0x73, 0x65, 0x74, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x5f, 0x6d, 0x69, 0x6e, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x65, 0x72, 0x22, 0x7d, 0x2c, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x2c, 0x22, 0x77, 0x69, 0x66, 0x69, 0x5f, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x65, 0x72, 0x22, 0x7d, 0x7d, 0x2c, 0x22, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x22, 0x3a, 0x5b, 0x5d, 0x2c, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x22, 0x7d, 0x2c, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x61, 0x72, 0x72, 0x61, 0x79, 0x22, 0x7d, 0x7d, 0x2c, 0x22, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x22, 0x3a, 0x5b, 0x5d, 0x2c, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x22, 0x7d}} + EconetService_SetModeTool = runtime.Tool{Name: "econet_v1alpha1_EconetService_SetMode", Description: "", RawInputSchema: json.RawMessage{0x7b, 0x22, 0x70, 0x72, 0x6f, 0x70, 0x65, 0x72, 0x74, 0x69, 0x65, 0x73, 0x22, 0x3a, 0x7b, 0x22, 0x6d, 0x6f, 0x64, 0x65, 0x22, 0x3a, 0x7b, 0x22, 0x65, 0x6e, 0x75, 0x6d, 0x22, 0x3a, 0x5b, 0x22, 0x4d, 0x4f, 0x44, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x22, 0x2c, 0x22, 0x4d, 0x4f, 0x44, 0x45, 0x5f, 0x4f, 0x46, 0x46, 0x22, 0x2c, 0x22, 0x4d, 0x4f, 0x44, 0x45, 0x5f, 0x45, 0x4c, 0x45, 0x43, 0x54, 0x52, 0x49, 0x43, 0x22, 0x2c, 0x22, 0x4d, 0x4f, 0x44, 0x45, 0x5f, 0x45, 0x4e, 0x45, 0x52, 0x47, 0x59, 0x5f, 0x53, 0x41, 0x56, 0x49, 0x4e, 0x47, 0x22, 0x2c, 0x22, 0x4d, 0x4f, 0x44, 0x45, 0x5f, 0x48, 0x45, 0x41, 0x54, 0x5f, 0x50, 0x55, 0x4d, 0x50, 0x22, 0x2c, 0x22, 0x4d, 0x4f, 0x44, 0x45, 0x5f, 0x48, 0x49, 0x47, 0x48, 0x5f, 0x44, 0x45, 0x4d, 0x41, 0x4e, 0x44, 0x22, 0x2c, 0x22, 0x4d, 0x4f, 0x44, 0x45, 0x5f, 0x47, 0x41, 0x53, 0x22, 0x2c, 0x22, 0x4d, 0x4f, 0x44, 0x45, 0x5f, 0x50, 0x45, 0x52, 0x46, 0x4f, 0x52, 0x4d, 0x41, 0x4e, 0x43, 0x45, 0x22, 0x2c, 0x22, 0x4d, 0x4f, 0x44, 0x45, 0x5f, 0x56, 0x41, 0x43, 0x41, 0x54, 0x49, 0x4f, 0x4e, 0x22, 0x5d, 0x2c, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x2c, 0x22, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x22, 0x3a, 0x7b, 0x22, 0x6d, 0x69, 0x6e, 0x4c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x22, 0x3a, 0x31, 0x2c, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x7d, 0x2c, 0x22, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x22, 0x3a, 0x5b, 0x5d, 0x2c, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x22, 0x7d}, RawOutputSchema: json.RawMessage{0x7b, 0x22, 0x70, 0x72, 0x6f, 0x70, 0x65, 0x72, 0x74, 0x69, 0x65, 0x73, 0x22, 0x3a, 0x7b, 0x22, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x22, 0x3a, 0x7b, 0x22, 0x70, 0x72, 0x6f, 0x70, 0x65, 0x72, 0x74, 0x69, 0x65, 0x73, 0x22, 0x3a, 0x7b, 0x22, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x65, 0x72, 0x22, 0x7d, 0x2c, 0x22, 0x61, 0x77, 0x61, 0x79, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x62, 0x6f, 0x6f, 0x6c, 0x65, 0x61, 0x6e, 0x22, 0x7d, 0x2c, 0x22, 0x63, 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x65, 0x64, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x62, 0x6f, 0x6f, 0x6c, 0x65, 0x61, 0x6e, 0x22, 0x7d, 0x2c, 0x22, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x2c, 0x22, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x62, 0x6f, 0x6f, 0x6c, 0x65, 0x61, 0x6e, 0x22, 0x7d, 0x2c, 0x22, 0x66, 0x72, 0x69, 0x65, 0x6e, 0x64, 0x6c, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x2c, 0x22, 0x67, 0x65, 0x6e, 0x65, 0x72, 0x69, 0x63, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x2c, 0x22, 0x68, 0x6f, 0x74, 0x5f, 0x77, 0x61, 0x74, 0x65, 0x72, 0x5f, 0x61, 0x76, 0x61, 0x69, 0x6c, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x65, 0x72, 0x22, 0x7d, 0x2c, 0x22, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x22, 0x3a, 0x7b, 0x22, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x22, 0x3a, 0x22, 0x64, 0x61, 0x74, 0x65, 0x2d, 0x74, 0x69, 0x6d, 0x65, 0x22, 0x2c, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x5b, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x2c, 0x22, 0x6e, 0x75, 0x6c, 0x6c, 0x22, 0x5d, 0x7d, 0x2c, 0x22, 0x6d, 0x6f, 0x64, 0x65, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x2c, 0x22, 0x72, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x62, 0x6f, 0x6f, 0x6c, 0x65, 0x61, 0x6e, 0x22, 0x7d, 0x2c, 0x22, 0x72, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x65, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x2c, 0x22, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x2c, 0x22, 0x73, 0x65, 0x74, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x65, 0x72, 0x22, 0x7d, 0x2c, 0x22, 0x73, 0x65, 0x74, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x5f, 0x6d, 0x61, 0x78, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x65, 0x72, 0x22, 0x7d, 0x2c, 0x22, 0x73, 0x65, 0x74, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x5f, 0x6d, 0x69, 0x6e, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x65, 0x72, 0x22, 0x7d, 0x2c, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x22, 0x7d, 0x2c, 0x22, 0x77, 0x69, 0x66, 0x69, 0x5f, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x6c, 0x22, 0x3a, 0x7b, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x65, 0x72, 0x22, 0x7d, 0x7d, 0x2c, 0x22, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x22, 0x3a, 0x5b, 0x5d, 0x2c, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x22, 0x7d, 0x7d, 0x2c, 0x22, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x22, 0x3a, 0x5b, 0x5d, 0x2c, 0x22, 0x74, 0x79, 0x70, 0x65, 0x22, 0x3a, 0x22, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x22, 0x7d}} ) // EconetServiceServer is compatible with the grpc-go server interface. type EconetServiceServer interface { GetDevice(ctx context.Context, req *v1alpha1.GetDeviceRequest) (*v1alpha1.GetDeviceResponse, error) ListDevices(ctx context.Context, req *v1alpha1.ListDevicesRequest) (*v1alpha1.ListDevicesResponse, error) + SetMode(ctx context.Context, req *v1alpha1.SetModeRequest) (*v1alpha1.SetModeResponse, error) } // RegisterEconetServiceHandler registers standard MCP handlers for EconetService @@ -118,6 +120,50 @@ func RegisterEconetServiceHandler(s runtime.MCPServer, srv EconetServiceServer, return nil, err } + return runtime.NewToolResultJSON(structured), nil + }) + } + SetModeTool := EconetService_SetModeTool + SetModeTool = runtime.ApplyConfig(SetModeTool, config) + + if config.ShouldRegister(SetModeTool.Name) { + s.AddTool(SetModeTool, func(ctx context.Context, request *runtime.CallToolRequest) (*runtime.CallToolResult, error) { + var req v1alpha1.SetModeRequest + + message := request.Arguments + + // Extract extra properties if configured + for _, prop := range config.ExtraProperties { + if propVal, ok := message[prop.Name]; ok { + ctx = context.WithValue(ctx, prop.ContextKey, propVal) + } + } + + // Rewrite oneof discriminated wrappers and recursion placeholders into the + // protojson-native shape. Errors are model-readable for self-correction. + if err := runtime.DecodeArguments(req.ProtoReflect().Descriptor(), message); err != nil { + return runtime.NewToolResultError(err.Error()), nil + } + + marshaled, err := json.Marshal(message) + if err != nil { + return nil, err + } + + if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(marshaled, &req); err != nil { + return nil, err + } + + resp, err := srv.SetMode(ctx, &req) + if err != nil { + return runtime.HandleError(err) + } + + structured, err := runtime.EncodeMessage(resp) + if err != nil { + return nil, err + } + return runtime.NewToolResultJSON(structured), nil }) } @@ -127,12 +173,14 @@ func RegisterEconetServiceHandler(s runtime.MCPServer, srv EconetServiceServer, type EconetServiceClient interface { GetDevice(ctx context.Context, req *v1alpha1.GetDeviceRequest, opts ...grpc.CallOption) (*v1alpha1.GetDeviceResponse, error) ListDevices(ctx context.Context, req *v1alpha1.ListDevicesRequest, opts ...grpc.CallOption) (*v1alpha1.ListDevicesResponse, error) + SetMode(ctx context.Context, req *v1alpha1.SetModeRequest, opts ...grpc.CallOption) (*v1alpha1.SetModeResponse, error) } // ConnectEconetServiceClient is compatible with the connectrpc-go client interface. type ConnectEconetServiceClient interface { GetDevice(ctx context.Context, req *connect.Request[v1alpha1.GetDeviceRequest]) (*connect.Response[v1alpha1.GetDeviceResponse], error) ListDevices(ctx context.Context, req *connect.Request[v1alpha1.ListDevicesRequest]) (*connect.Response[v1alpha1.ListDevicesResponse], error) + SetMode(ctx context.Context, req *connect.Request[v1alpha1.SetModeRequest]) (*connect.Response[v1alpha1.SetModeResponse], error) } // ForwardToConnectEconetServiceClient registers a connectrpc client, to forward MCP calls to it. @@ -216,6 +264,47 @@ func ForwardToConnectEconetServiceClient(s runtime.MCPServer, client ConnectEcon return runtime.HandleError(err) } + structured, err := runtime.EncodeMessage(resp.Msg) + if err != nil { + return nil, err + } + return runtime.NewToolResultJSON(structured), nil + }) + } + SetModeTool := EconetService_SetModeTool + SetModeTool = runtime.ApplyConfig(SetModeTool, config) + + if config.ShouldRegister(SetModeTool.Name) { + s.AddTool(SetModeTool, func(ctx context.Context, request *runtime.CallToolRequest) (*runtime.CallToolResult, error) { + var req v1alpha1.SetModeRequest + + message := request.Arguments + + // Extract extra properties if configured + for _, prop := range config.ExtraProperties { + if propVal, ok := message[prop.Name]; ok { + ctx = context.WithValue(ctx, prop.ContextKey, propVal) + } + } + + if err := runtime.DecodeArguments(req.ProtoReflect().Descriptor(), message); err != nil { + return runtime.NewToolResultError(err.Error()), nil + } + + marshaled, err := json.Marshal(message) + if err != nil { + return nil, err + } + + if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(marshaled, &req); err != nil { + return nil, err + } + + resp, err := client.SetMode(ctx, connect.NewRequest(&req)) + if err != nil { + return runtime.HandleError(err) + } + structured, err := runtime.EncodeMessage(resp.Msg) if err != nil { return nil, err @@ -306,6 +395,47 @@ func ForwardToEconetServiceClient(s runtime.MCPServer, client EconetServiceClien return runtime.HandleError(err) } + structured, err := runtime.EncodeMessage(resp) + if err != nil { + return nil, err + } + return runtime.NewToolResultJSON(structured), nil + }) + } + SetModeTool := EconetService_SetModeTool + SetModeTool = runtime.ApplyConfig(SetModeTool, config) + + if config.ShouldRegister(SetModeTool.Name) { + s.AddTool(SetModeTool, func(ctx context.Context, request *runtime.CallToolRequest) (*runtime.CallToolResult, error) { + var req v1alpha1.SetModeRequest + + message := request.Arguments + + // Extract extra properties if configured + for _, prop := range config.ExtraProperties { + if propVal, ok := message[prop.Name]; ok { + ctx = context.WithValue(ctx, prop.ContextKey, propVal) + } + } + + if err := runtime.DecodeArguments(req.ProtoReflect().Descriptor(), message); err != nil { + return runtime.NewToolResultError(err.Error()), nil + } + + marshaled, err := json.Marshal(message) + if err != nil { + return nil, err + } + + if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(marshaled, &req); err != nil { + return nil, err + } + + resp, err := client.SetMode(ctx, &req) + if err != nil { + return runtime.HandleError(err) + } + structured, err := runtime.EncodeMessage(resp) if err != nil { return nil, err diff --git a/contrib/econet-exporter.swagger.json b/contrib/econet-exporter.swagger.json index 7afe369..3f0b0bf 100644 --- a/contrib/econet-exporter.swagger.json +++ b/contrib/econet-exporter.swagger.json @@ -67,9 +67,55 @@ "EconetService" ] } + }, + "/v1alpha1/devices/{serialNumber}/mode": { + "post": { + "operationId": "EconetService_SetMode", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1alpha1SetModeResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "serialNumber", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/EconetServiceSetModeBody" + } + } + ], + "tags": [ + "EconetService" + ] + } } }, "definitions": { + "EconetServiceSetModeBody": { + "type": "object", + "properties": { + "mode": { + "$ref": "#/definitions/v1alpha1Mode" + } + } + }, "protobufAny": { "type": "object", "properties": { @@ -190,6 +236,31 @@ } } } + }, + "v1alpha1Mode": { + "type": "string", + "enum": [ + "MODE_UNSPECIFIED", + "MODE_OFF", + "MODE_ELECTRIC", + "MODE_ENERGY_SAVING", + "MODE_HEAT_PUMP", + "MODE_HIGH_DEMAND", + "MODE_GAS", + "MODE_PERFORMANCE", + "MODE_VACATION" + ], + "default": "MODE_UNSPECIFIED", + "description": "Mode is a settable operating mode for a water heater. The values mirror the\nkebab-case slugs reported in Device.mode. Not every device supports every\nmode; the supported set is device-specific (derived from the unit's reported\nmode list), so SetMode validates against the target device." + }, + "v1alpha1SetModeResponse": { + "type": "object", + "properties": { + "device": { + "$ref": "#/definitions/v1alpha1Device", + "description": "Last-known device state. The mode change is applied asynchronously by the\nRheem cloud, so this reflects the requested mode only after a later poll." + } + } } } } diff --git a/pkg/econet/econetclient/client.go b/pkg/econet/econetclient/client.go index 2c476fa..1b0e6ef 100644 --- a/pkg/econet/econetclient/client.go +++ b/pkg/econet/econetclient/client.go @@ -113,6 +113,53 @@ func (c *Client) Refresh(ctx context.Context) error { return nil } +// SetMode changes a device's operating mode. It resolves the normalized mode +// slug to the device's @MODE enum index and publishes a desired-state message +// to the EcoNet cloud over ClearBlade's REST publish endpoint (the HTTP +// equivalent of the app's MQTT publish). The change is applied asynchronously; +// the new mode is only reflected after a subsequent Refresh. +func (c *Client) SetMode(ctx context.Context, serial, mode string) error { + c.mu.RLock() + d, account := c.devices[serial], c.accountID + c.mu.RUnlock() + if d == nil { + return fmt.Errorf("econet: unknown device %q", serial) + } + idx, ok := d.modeIndex(mode) + if !ok { + return fmt.Errorf("econet: device %q does not support mode %q (supported: %s)", + serial, mode, strings.Join(d.SupportedModes(), ", ")) + } + + if err := c.ensureAuth(ctx); err != nil { + return err + } + + desired, err := json.Marshal(map[string]any{ + "transactionId": transactionID(), + "device_name": d.DeviceID, + "serial_number": d.SerialNumber, + "@MODE": idx, + }) + if err != nil { + return err + } + publish := map[string]any{ + "topic": "user/" + account + "/device/desired", + "body": string(desired), + "qos": 1, + } + + path := "/message/" + clearBladeSystemKey + "/publish" + if err := c.do(ctx, path, c.userToken(), publish, nil); err != nil { + c.clearToken() // token may have expired; re-auth next call + return fmt.Errorf("econet: set mode: %w", err) + } + c.log.Info().Str("serial", serial).Str("mode", mode).Int("modeIndex", idx). + Msg("econet: published mode change") + return nil +} + func (c *Client) clearToken() { c.mu.Lock() c.token = "" @@ -274,6 +321,11 @@ func (c *Client) do(ctx context.Context, path, token string, body, out any) erro if resp.StatusCode != http.StatusOK { return fmt.Errorf("HTTP %d: %s", resp.StatusCode, bytes.TrimSpace(raw)) } + // The publish endpoint replies 200 with an empty (or non-JSON) body; callers + // that don't need the response pass out == nil. + if out == nil || len(bytes.TrimSpace(raw)) == 0 { + return nil + } return json.Unmarshal(raw, out) } @@ -285,6 +337,12 @@ func (c *Client) userToken() string { // --- usage helpers ------------------------------------------------------- +// transactionID mirrors the identifier the Rheem Android app stamps on each +// desired-state publish: "ANDROID_" + a local second-resolution timestamp. +func transactionID() string { + return "ANDROID_" + time.Now().Format("2006-01-02T15:04:05") +} + // dayWindow returns ISO-8601 start/end strings covering the current local day, // matching pyeconet's formats: energy includes the timezone offset, water does // not (the upstream parser is picky). diff --git a/pkg/econet/econetclient/device.go b/pkg/econet/econetclient/device.go index 325cc6d..5524902 100644 --- a/pkg/econet/econetclient/device.go +++ b/pkg/econet/econetclient/device.go @@ -26,6 +26,11 @@ type Device struct { Running bool // true when actively heating (decoded from @RUNNING over REST) RunningState string // normalized @RUNNING label: "compressor-running" | "element-running" | "idle" | ... + // modeEnumText is the raw @MODE enum label list; the slice index is the + // integer value published to change the mode. Empty for units without a + // settable @MODE (mode changes are unsupported for those). + modeEnumText []string + Setpoint int SetpointMin int SetpointMax int @@ -62,9 +67,31 @@ func newDevice(info map[string]json.RawMessage, now time.Time) *Device { d.FriendlyName = friendlyName(info, d.DeviceID) d.Setpoint, d.SetpointMin, d.SetpointMax = setpoint(info["@SETPOINT"]) d.Running, d.RunningState = running(info) + d.modeEnumText = modeLabels(info) return d } +// SupportedModes returns the normalized mode slugs this device can be set to, +// in publish-index order. Empty for units without a settable @MODE. +func (d *Device) SupportedModes() []string { + out := make([]string, 0, len(d.modeEnumText)) + for _, label := range d.modeEnumText { + out = append(out, modeFromEnumText(label)) + } + return out +} + +// modeIndex maps a normalized mode slug to the integer value the EcoNet cloud +// expects in a @MODE publish, matching pyeconet's enumText-position lookup. +func (d *Device) modeIndex(slug string) (int, bool) { + for i, label := range d.modeEnumText { + if modeFromEnumText(label) == slug { + return i, true + } + } + return 0, false +} + // --- field decoders ------------------------------------------------------ func connected(info map[string]json.RawMessage) bool { diff --git a/pkg/econet/econetgrpc/econet.go b/pkg/econet/econetgrpc/econet.go index d9e324a..c47b294 100644 --- a/pkg/econet/econetgrpc/econet.go +++ b/pkg/econet/econetgrpc/econet.go @@ -70,3 +70,49 @@ func (s *EconetGRPCServer) GetDevice(ctx context.Context, req *pb.GetDeviceReque span.SetStatus(codes.Ok, "") return &pb.GetDeviceResponse{Device: deviceToProto(d)}, nil } + +// modeSlugs maps the settable proto Mode enum to the normalized slugs the +// client (and Device.mode) use. MODE_UNSPECIFIED is intentionally absent; +// protovalidate rejects it before we get here. +var modeSlugs = map[pb.Mode]string{ + pb.Mode_MODE_OFF: "off", + pb.Mode_MODE_ELECTRIC: "electric", + pb.Mode_MODE_ENERGY_SAVING: "energy-saving", + pb.Mode_MODE_HEAT_PUMP: "heat-pump", + pb.Mode_MODE_HIGH_DEMAND: "high-demand", + pb.Mode_MODE_GAS: "gas", + pb.Mode_MODE_PERFORMANCE: "performance", + pb.Mode_MODE_VACATION: "vacation", +} + +func (s *EconetGRPCServer) SetMode(ctx context.Context, req *pb.SetModeRequest) ( + *pb.SetModeResponse, error, +) { + ctx, span := s.tracer.Start(ctx, "setMode", trace.WithAttributes( + attribute.String("serialNumber", req.GetSerialNumber()), + attribute.String("mode", req.GetMode().String()), + )) + defer span.End() + + slug, ok := modeSlugs[req.GetMode()] + if !ok { + err := status.Errorf(grpccodes.InvalidArgument, "unsupported mode %q", req.GetMode()) + span.SetStatus(codes.Error, err.Error()) + return nil, err + } + + if err := s.client.SetMode(ctx, req.GetSerialNumber(), slug); err != nil { + err := status.Error(grpccodes.FailedPrecondition, err.Error()) + span.SetStatus(codes.Error, err.Error()) + return nil, err + } + + span.SetStatus(codes.Ok, "") + // State applies asynchronously; return the last-known snapshot. Guard against + // a concurrent refresh evicting the device between publish and lookup. + resp := &pb.SetModeResponse{} + if d := s.client.Device(req.GetSerialNumber()); d != nil { + resp.Device = deviceToProto(d) + } + return resp, nil +} diff --git a/proto/econet/v1alpha1/econet.proto b/proto/econet/v1alpha1/econet.proto index b67e027..d7c77d6 100644 --- a/proto/econet/v1alpha1/econet.proto +++ b/proto/econet/v1alpha1/econet.proto @@ -30,6 +30,22 @@ message Device { google.protobuf.Timestamp last_updated = 18; } +// Mode is a settable operating mode for a water heater. The values mirror the +// kebab-case slugs reported in Device.mode. Not every device supports every +// mode; the supported set is device-specific (derived from the unit's reported +// mode list), so SetMode validates against the target device. +enum Mode { + MODE_UNSPECIFIED = 0; + MODE_OFF = 1; + MODE_ELECTRIC = 2; + MODE_ENERGY_SAVING = 3; + MODE_HEAT_PUMP = 4; + MODE_HIGH_DEMAND = 5; + MODE_GAS = 6; + MODE_PERFORMANCE = 7; + MODE_VACATION = 8; +} + message ListDevicesRequest {} message ListDevicesResponse { @@ -44,6 +60,20 @@ message GetDeviceResponse { Device device = 1; } +message SetModeRequest { + string serial_number = 1 [(buf.validate.field).string.min_len = 1]; + Mode mode = 2 [(buf.validate.field).enum = { + defined_only: true + not_in: [0] + }]; +} + +message SetModeResponse { + // Last-known device state. The mode change is applied asynchronously by the + // Rheem cloud, so this reflects the requested mode only after a later poll. + Device device = 1; +} + // EconetService exposes read-only access to Rheem EcoNet device state. service EconetService { rpc ListDevices(ListDevicesRequest) returns (ListDevicesResponse) { @@ -52,4 +82,10 @@ service EconetService { rpc GetDevice(GetDeviceRequest) returns (GetDeviceResponse) { option (google.api.http) = {get: "/v1alpha1/devices/{serial_number}"}; } + rpc SetMode(SetModeRequest) returns (SetModeResponse) { + option (google.api.http) = { + post: "/v1alpha1/devices/{serial_number}/mode" + body: "*" + }; + } }