diff --git a/codecs/rubixDataEncoding/decoder.go b/codecs/rubixDataEncoding/decoder.go index 8ae5b4e..1710f4c 100644 --- a/codecs/rubixDataEncoding/decoder.go +++ b/codecs/rubixDataEncoding/decoder.go @@ -475,3 +475,37 @@ func GetRubixPointNames() []string { func CheckPayloadLengthRubix(_ string) bool { return true } + +// DecodeRequestPayload parses a LORA RAW PROTOCOL §2.2 request body: +// +// [SETTINGS_BYTE] [RDE message ID] [POINT_ID] [POINT_ID] ... +// +// A request names what is being asked for and carries no DATA_TYPE_ID and no +// values, which is why it cannot go through DecodeRubix. Returns the requested +// points as IoNumber strings (e.g. "UVP-1"). Unknown or unexpected POINT_IDs +// are not an error: the device and gateway version independently, so any +// PositionDataType decodes to some name via generateFieldName. +// +// Nothing here is specific to configuration - a request names points, whatever +// those points happen to mean. +func DecodeRequestPayload(payload []byte) ([]string, error) { + if len(payload) < 1 { + return nil, errors.New("config request payload is empty") + } + serialData := NewSerialDataWithBuffer(payload) + if !HasRequestData(serialData) { + return nil, errors.New("config request payload does not have the request flag set") + } + if len(payload) < 2 { + return nil, errors.New("config request payload is truncated: missing message ID byte") + } + UpdateBitPositionsForHeaderByte(serialData) + + names := make([]string, 0, len(payload)-2) + for serialData.ReadBitPos+8 <= len(serialData.Buffer)*8 { + positionVector, shiftPos, bytesRequired := getVector(serialData, 8, serialData.ReadBitPos) + positionByte := uint8(vectorToBits(positionVector, 8, shiftPos, bytesRequired)) + names = append(names, generateFieldName(MDK_UINT_16, parsePosition(positionByte))) + } + return names, nil +} diff --git a/codecs/rubixDataEncoding/decoder_request_test.go b/codecs/rubixDataEncoding/decoder_request_test.go new file mode 100644 index 0000000..9176294 --- /dev/null +++ b/codecs/rubixDataEncoding/decoder_request_test.go @@ -0,0 +1,73 @@ +package rubixDataEncoding + +import "testing" + +// buildRequestPayload constructs a §2.2 request body the way the firmware +// does: settings byte with the request flag, the RDE message ID, then one +// POINT_ID byte per requested point. +func buildRequestPayload(msgID uint8, positions []uint8) []byte { + sd := NewSerialData() + SetRequestData(sd, true) + SetMessageId(sd, msgID) + sd.Buffer = append(sd.Buffer, positions...) + return sd.Buffer +} + +func TestDecodeRequestPayloadSinglePoint(t *testing.T) { + // UVP-1 => type UVP in the high 3 bits, index 0 in the low 5. + uvp1 := uint8(PositionDataType_UVP)<<5 | 0 + got, err := DecodeRequestPayload(buildRequestPayload(0x42, []uint8{uvp1})) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 1 || got[0] != "UVP-1" { + t.Fatalf("got %v, want [UVP-1]", got) + } +} + +func TestDecodeRequestPayloadMultiplePoints(t *testing.T) { + uvp1 := uint8(PositionDataType_UVP)<<5 | 0 + // UVP-40 => pointIdx 39 is >=32, so per getPosition/generateFieldName it is + // encoded as PositionDataType_UVP2 with ID 39-32=7 (id+32 => 40). The ID + // field is only 5 bits (0-31), so PositionDataType_UVP<<5|39 would collide + // with the type bits and decode to the wrong point. + uvp40 := uint8(PositionDataType_UVP2)<<5 | 7 + got, err := DecodeRequestPayload(buildRequestPayload(0x07, []uint8{uvp1, uvp40})) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 2 || got[0] != "UVP-1" || got[1] != "UVP-40" { + t.Fatalf("got %v, want [UVP-1 UVP-40]", got) + } +} + +// A payload without the request flag is a data packet, not a request. +func TestDecodeRequestPayloadRejectsNonRequest(t *testing.T) { + if _, err := DecodeRequestPayload([]byte{0x00, 0x20}); err == nil { + t.Fatal("expected an error when the request flag is clear") + } +} + +func TestDecodeRequestPayloadRejectsTruncated(t *testing.T) { + // Request flag set but the message ID byte never arrived. + if _, err := DecodeRequestPayload([]byte{0x02}); err == nil { + t.Fatal("expected an error on a truncated request") + } +} + +func TestDecodeRequestPayloadRejectsEmpty(t *testing.T) { + if _, err := DecodeRequestPayload(nil); err == nil { + t.Fatal("expected an error on an empty payload") + } +} + +// No POINT_IDs after the header is well-formed but empty; it must not panic. +func TestDecodeRequestPayloadEmptyPointList(t *testing.T) { + got, err := DecodeRequestPayload(buildRequestPayload(0x01, nil)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 0 { + t.Fatalf("got %v, want empty", got) + } +} diff --git a/codecs/rubixDataEncoding/serialdata.go b/codecs/rubixDataEncoding/serialdata.go index 3eeee3b..c6d438d 100644 --- a/codecs/rubixDataEncoding/serialdata.go +++ b/codecs/rubixDataEncoding/serialdata.go @@ -72,3 +72,66 @@ func setPositionalData(serialData *SerialData, set bool) { func hasPositionalData(serialData *SerialData) bool { return serialData.Buffer[0]&1 == 1 } + +// SETTINGS_BYTE bit layout (LORA RAW PROTOCOL §3.3, RDE v1.1.x). Mirrors +// Core/Inc/serialData.hpp in the device firmware; keep the two in step. +// +// bit 0 - positional flag: POINT_IDs are present in the data packets +// bit 1 - request flag: payload is POINT_IDs only, no DATA_TYPE_ID/values +// bit 2 - response flag: payload answers a request +// +// When bit 1 or bit 2 is set, Buffer[1] holds the RDE message ID and the +// first data packet starts one byte later. + +func SetRequestData(serialData *SerialData, set bool) { + serialData.Buffer[0] = BIT_SET(serialData.Buffer[0], set, 1) + SetMessageId(serialData, 0) +} + +func HasRequestData(serialData *SerialData) bool { + return serialData.Buffer[0]&2 == 2 +} + +func SetResponseData(serialData *SerialData, set bool) { + serialData.Buffer[0] = BIT_SET(serialData.Buffer[0], set, 2) + SetMessageId(serialData, 0) +} + +func HasResponseData(serialData *SerialData) bool { + return serialData.Buffer[0]&4 == 4 +} + +func HasPositionalData(serialData *SerialData) bool { + return hasPositionalData(serialData) +} + +// SetMessageId writes the RDE message ID, reserving Buffer[1] if the buffer +// has not grown that far yet. It is a no-op unless a request or response flag +// is set, matching the firmware's setMessageId precondition. +func SetMessageId(serialData *SerialData, id uint8) { + if !HasRequestData(serialData) && !HasResponseData(serialData) { + return + } + if len(serialData.Buffer) > 1 { + serialData.Buffer[1] = id + return + } + serialData.Buffer = append(serialData.Buffer, id) +} + +func GetMessageId(serialData *SerialData) uint8 { + if len(serialData.Buffer) < 2 { + return 0 + } + return serialData.Buffer[1] +} + +// UpdateBitPositionsForHeaderByte advances the read position past the RDE +// message ID byte. Must be called before canDecode/decodeData on any buffer +// that may carry a request or response flag. No-op when both flags are clear, +// so plain uplink decoding is unaffected. +func UpdateBitPositionsForHeaderByte(serialData *SerialData) { + if HasRequestData(serialData) || HasResponseData(serialData) { + serialData.ReadBitPos += 8 + } +} diff --git a/codecs/rubixDataEncoding/serialdata_test.go b/codecs/rubixDataEncoding/serialdata_test.go new file mode 100644 index 0000000..080ccba --- /dev/null +++ b/codecs/rubixDataEncoding/serialdata_test.go @@ -0,0 +1,65 @@ +package rubixDataEncoding + +import "testing" + +func TestRequestFlagRoundTrip(t *testing.T) { + sd := NewSerialData() + if HasRequestData(sd) { + t.Fatal("request flag should start clear") + } + SetRequestData(sd, true) + if !HasRequestData(sd) { + t.Fatal("request flag should be set") + } + if HasResponseData(sd) { + t.Fatal("setting request must not set response") + } + if HasPositionalData(sd) { + t.Fatal("setting request must not set positional") + } +} + +// SetRequestData must reserve Buffer[1] for the message ID, mirroring the +// firmware's setRequestData -> setMessageId(0) behaviour. +func TestSetRequestDataReservesMessageIdByte(t *testing.T) { + sd := NewSerialData() + SetRequestData(sd, true) + if len(sd.Buffer) < 2 { + t.Fatalf("expected a message ID byte at Buffer[1], buffer len = %d", len(sd.Buffer)) + } + SetMessageId(sd, 0x5A) + if got := GetMessageId(sd); got != 0x5A { + t.Fatalf("message ID = %#x, want 0x5A", got) + } +} + +// With both flags clear the read position must be untouched, so existing +// uplink decoding is byte-identical to before this change. +func TestUpdateBitPositionsIsNoopWhenFlagsClear(t *testing.T) { + sd := NewSerialDataWithBuffer([]byte{0x00, 0x11, 0x22}) + before := sd.ReadBitPos + UpdateBitPositionsForHeaderByte(sd) + if sd.ReadBitPos != before { + t.Fatalf("ReadBitPos moved from %d to %d with flags clear", before, sd.ReadBitPos) + } +} + +func TestUpdateBitPositionsSkipsMessageIdByte(t *testing.T) { + for _, tc := range []struct { + name string + set func(*SerialData) + }{ + {"request", func(sd *SerialData) { SetRequestData(sd, true) }}, + {"response", func(sd *SerialData) { SetResponseData(sd, true) }}, + } { + t.Run(tc.name, func(t *testing.T) { + sd := NewSerialData() + tc.set(sd) + before := sd.ReadBitPos + UpdateBitPositionsForHeaderByte(sd) + if sd.ReadBitPos != before+8 { + t.Fatalf("ReadBitPos = %d, want %d", sd.ReadBitPos, before+8) + } + }) + } +} diff --git a/pkg/app.go b/pkg/app.go index c571353..1eeb5f0 100644 --- a/pkg/app.go +++ b/pkg/app.go @@ -482,6 +482,10 @@ func (m *Module) handleLoRaRAWDevice(device *model.Device, devDesc *codec.LoRaDe } msgId := dataBytes[utils.LORARAW_NONCE_POSITION] _ = devDesc.DecodeResponse(dataHex, payload, msgId, devDesc, device, writtenSuccessFn, writtenErrorFn, metaFn) + case utils.LORARAW_OPTS_REQUEST: + // A device asking us for point values (§2.2). Answered inline, not via + // the write queue — the device's RX window is ~1s. + m.handleInboundRequest(device, devDesc, payload, dataBytes, keyBytes) default: log.Warnf("unhandled LoRaRAW option: %d", opts) } diff --git a/pkg/configSync.go b/pkg/configSync.go new file mode 100644 index 0000000..a9cbcb4 --- /dev/null +++ b/pkg/configSync.go @@ -0,0 +1,68 @@ +package pkg + +import ( + "strconv" + + "github.com/NubeIO/module-core-loraraw/codecs/rubixDataEncoding" + "github.com/NubeIO/nubeio-rubix-lib-models-go/model" + log "github.com/sirupsen/logrus" +) + +// Configuration sync is one user of the LoRaRAW request/response exchange, not +// the exchange itself - see loraRawRequest.go for the mechanism. Everything in +// this file is specific to the push rate. + +// pushRateIoNumber is the point the device uses for its push rate (tdc_s). +// Future config items start at UVP-40 to avoid colliding with telemetry slots. +const pushRateIoNumber = "UVP-1" + +// Bounds enforced by AT_TDC_set in the firmware (lora_at.c). Keep in step. +const ( + minPushRateSeconds = 1 + maxPushRateSeconds = 15000 +) + +// resolveDesiredRate returns the push rate to send back for ioNumber. The +// operator's WriteValue wins; otherwise the device is echoed its own last +// reported value so it gets a definitive answer rather than waiting out its +// RX window. Returns false when the point is absent or has no usable value. +func resolveDesiredRate(device *model.Device, ioNumber string) (float64, bool) { + if device == nil { + return 0, false + } + for _, pnt := range device.Points { + if pnt == nil || pnt.IoNumber != ioNumber { + continue + } + for _, candidate := range []*float64{pnt.WriteValue, pnt.PresentValue} { + if candidate == nil { + continue + } + v := *candidate + if v < minPushRateSeconds || v > maxPushRateSeconds { + log.Warnf("configSync: push rate %v for %s is outside %d..%d, ignoring", + v, ioNumber, minPushRateSeconds, maxPushRateSeconds) + continue + } + return v, true + } + return 0, false + } + return 0, false +} + +// resolvePushRatePoint packages the resolved rate as the point the generic +// request handler will encode. MDK_UINT_16 rather than MDK_PUSH_FREQUENCY: +// that key's 0..2000 range is narrower than the 1..15000 the firmware accepts. +func resolvePushRatePoint(device *model.Device) (*model.Point, bool) { + rate, ok := resolveDesiredRate(device, pushRateIoNumber) + if !ok { + return nil, false + } + value := rate + return &model.Point{ + IoNumber: pushRateIoNumber, + DataType: strconv.Itoa(int(rubixDataEncoding.MDK_UINT_16)), + WriteValue: &value, + }, true +} diff --git a/pkg/configSync_test.go b/pkg/configSync_test.go new file mode 100644 index 0000000..5d26613 --- /dev/null +++ b/pkg/configSync_test.go @@ -0,0 +1,77 @@ +package pkg + +import ( + "testing" + + "github.com/NubeIO/nubeio-rubix-lib-models-go/model" +) + +func ratePtr(v float64) *float64 { return &v } + +func pushRatePoint(present, write *float64) *model.Point { + p := &model.Point{IoNumber: "UVP-1"} + p.PresentValue = present + p.WriteValue = write + return p +} + +// The operator's setpoint wins when present. +func TestResolveDesiredRatePrefersWriteValue(t *testing.T) { + dev := &model.Device{Points: []*model.Point{ + pushRatePoint(ratePtr(600), ratePtr(900)), + }} + got, ok := resolveDesiredRate(dev, pushRateIoNumber) + if !ok { + t.Fatal("expected a rate to resolve") + } + if got != 900 { + t.Fatalf("rate = %v, want 900 (the write value)", got) + } +} + +// With no setpoint the device is told to keep what it reported, so it gets a +// definitive answer instead of waiting out its RX window. +func TestResolveDesiredRateFallsBackToPresentValue(t *testing.T) { + dev := &model.Device{Points: []*model.Point{ + pushRatePoint(ratePtr(600), nil), + }} + got, ok := resolveDesiredRate(dev, pushRateIoNumber) + if !ok || got != 600 { + t.Fatalf("rate = %v ok = %v, want 600 true", got, ok) + } +} + +func TestResolveDesiredRateMissingPoint(t *testing.T) { + dev := &model.Device{Points: []*model.Point{}} + if _, ok := resolveDesiredRate(dev, pushRateIoNumber); ok { + t.Fatal("expected no rate when the point does not exist") + } +} + +func TestResolveDesiredRateRejectsOutOfRange(t *testing.T) { + for _, v := range []float64{0, 15001, -5} { + dev := &model.Device{Points: []*model.Point{ + pushRatePoint(nil, ratePtr(v)), + }} + if _, ok := resolveDesiredRate(dev, pushRateIoNumber); ok { + t.Fatalf("rate %v should have been rejected as out of range", v) + } + } +} + +// The inclusive boundaries of the valid range (1..15000) must be accepted, +// not just rejected just outside them. +func TestResolveDesiredRateAcceptsBoundaries(t *testing.T) { + for _, v := range []float64{1, 15000} { + dev := &model.Device{Points: []*model.Point{ + pushRatePoint(nil, ratePtr(v)), + }} + got, ok := resolveDesiredRate(dev, pushRateIoNumber) + if !ok { + t.Fatalf("rate %v should have been accepted as within range", v) + } + if got != v { + t.Fatalf("rate = %v, want %v", got, v) + } + } +} diff --git a/pkg/loraRawRequest.go b/pkg/loraRawRequest.go new file mode 100644 index 0000000..09508db --- /dev/null +++ b/pkg/loraRawRequest.go @@ -0,0 +1,124 @@ +package pkg + +import ( + "errors" + + "github.com/NubeIO/lib-utils-go/nstring" + "github.com/NubeIO/module-core-loraraw/aesutils" + "github.com/NubeIO/module-core-loraraw/codec" + "github.com/NubeIO/module-core-loraraw/codecs/rubixDataEncoding" + "github.com/NubeIO/module-core-loraraw/utils" + "github.com/NubeIO/nubeio-rubix-lib-models-go/model" + log "github.com/sirupsen/logrus" +) + +// The LoRaRAW request/response exchange (§2.2) is a general mechanism: a device +// names the points it wants and the gateway answers with their values. Nothing +// about it is specific to configuration - configuration just happens to be the +// first thing a device asks for. Anything that knows what an individual point +// means belongs behind resolveRequestedPoint, not in this file. + +// resolveRequestedPoint returns the point to answer with for ioNumber, or false +// when this gateway has nothing to say about it. This is the single hook where +// per-point knowledge lives; new request-able points are added here. +func resolveRequestedPoint(device *model.Device, ioNumber string) (*model.Point, bool) { + switch ioNumber { + case pushRateIoNumber: + return resolvePushRatePoint(device) + default: + return nil, false + } +} + +// buildResponsePayload encodes a §2.2 response body: +// +// [SETTINGS_BYTE with response flag] [RDE message ID] [POINT_ID][DATA_TYPE_ID][value]... +func buildResponsePayload(msgID uint8, points []*model.Point) ([]byte, error) { + if len(points) == 0 { + return nil, errors.New("no points to encode into a response") + } + + body, err := rubixDataEncoding.EncodeRequestMessage(points) + if err != nil { + return nil, err + } + if len(body) < 1 { + return nil, errors.New("encoder produced an empty response body") + } + + // EncodeRequestMessage emits [settings][data...]. Splice in the response + // flag and the RDE message ID, which must sit at index 1. + sd := rubixDataEncoding.NewSerialDataWithBuffer([]byte{body[0]}) + rubixDataEncoding.SetResponseData(sd, true) + rubixDataEncoding.SetMessageId(sd, msgID) + + out := make([]byte, 0, len(body)+1) + out = append(out, sd.Buffer...) // [settings][mid] + out = append(out, body[1:]...) // data packets + return out, nil +} + +// handleInboundRequest answers a device's LORARAW_OPTS_REQUEST. It replies +// synchronously via WriteToLoRaRaw - never through the write queue, whose +// time-off-air sleep would miss the device's ~1s RX window. +func (m *Module) handleInboundRequest( + device *model.Device, + _ *codec.LoRaDeviceDescription, + payload []byte, + dataBytes []byte, + keyBytes []byte, +) { + if len(dataBytes) <= utils.LORARAW_NONCE_POSITION { + log.Errorf("loraRawRequest: frame too short for a request: length %d, need at least %d", + len(dataBytes), utils.LORARAW_NONCE_POSITION+1) + return + } + msgID := dataBytes[utils.LORARAW_NONCE_POSITION] + + requested, err := rubixDataEncoding.DecodeRequestPayload(payload) + if err != nil { + log.Errorf("loraRawRequest: cannot decode request: %s", err) + return + } + log.Infof("loraRawRequest: device %s requested %v (mid=%d)", device.UUID, requested, msgID) + + // Answer what was asked for rather than a fixed point: a device asking for + // something this gateway does not serve gets that entry dropped, not a + // value it never requested. + points := make([]*model.Point, 0, len(requested)) + for _, ioNumber := range requested { + point, ok := resolveRequestedPoint(device, ioNumber) + if !ok { + log.Warnf("loraRawRequest: nothing to answer for %s on device %s", ioNumber, device.UUID) + continue + } + points = append(points, point) + } + if len(points) == 0 { + log.Warnf("loraRawRequest: no answerable points for device %s, not responding", device.UUID) + return + } + + body, err := buildResponsePayload(msgID, points) + if err != nil { + log.Errorf("loraRawRequest: cannot encode response: %s", err) + return + } + + frame, err := aesutils.Encrypt( + nstring.DerefString(device.AddressUUID), + body, + keyBytes, + utils.LORARAW_OPTS_RESPONSE, + msgID, + ) + if err != nil { + log.Errorf("loraRawRequest: cannot encrypt response: %s", err) + return + } + if err := m.WriteToLoRaRaw(frame); err != nil { + log.Errorf("loraRawRequest: cannot send response: %s", err) + return + } + log.Infof("loraRawRequest: answered device %s with %d point(s) (mid=%d)", device.UUID, len(points), msgID) +} diff --git a/pkg/loraRawRequest_test.go b/pkg/loraRawRequest_test.go new file mode 100644 index 0000000..3152455 --- /dev/null +++ b/pkg/loraRawRequest_test.go @@ -0,0 +1,56 @@ +package pkg + +import ( + "testing" + + "github.com/NubeIO/nubeio-rubix-lib-models-go/model" +) + +// The response body must carry the response flag and echo the request's MID. +func TestBuildResponsePayload(t *testing.T) { + point, ok := resolvePushRatePoint(&model.Device{Points: []*model.Point{ + pushRatePoint(nil, ratePtr(900)), + }}) + if !ok { + t.Fatal("expected the push rate point to resolve") + } + + body, err := buildResponsePayload(0x5A, []*model.Point{point}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(body) < 3 { + t.Fatalf("body too short: %d bytes", len(body)) + } + if body[0]&4 != 4 { + t.Fatalf("settings byte %#x does not have the response flag set", body[0]) + } + if body[1] != 0x5A { + t.Fatalf("message ID = %#x, want 0x5A", body[1]) + } +} + +// Encoding nothing is an error rather than an empty frame: a response with no +// points would leave the device waiting out its RX window for a value that is +// not there. +func TestBuildResponsePayloadRejectsNoPoints(t *testing.T) { + if _, err := buildResponsePayload(0x5A, nil); err == nil { + t.Fatal("expected an error when there are no points to encode") + } +} + +// The mechanism is not config-specific, but it only answers points it has a +// resolver for - anything else is dropped rather than answered with a value +// the device never asked about. +func TestResolveRequestedPoint(t *testing.T) { + dev := &model.Device{Points: []*model.Point{ + pushRatePoint(nil, ratePtr(900)), + }} + + if _, ok := resolveRequestedPoint(dev, pushRateIoNumber); !ok { + t.Fatalf("expected %s to resolve", pushRateIoNumber) + } + if _, ok := resolveRequestedPoint(dev, "UVP-40"); ok { + t.Fatal("expected an unserved point to be refused, not answered") + } +}