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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions codecs/rubixDataEncoding/decoder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
73 changes: 73 additions & 0 deletions codecs/rubixDataEncoding/decoder_request_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
63 changes: 63 additions & 0 deletions codecs/rubixDataEncoding/serialdata.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
65 changes: 65 additions & 0 deletions codecs/rubixDataEncoding/serialdata_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
4 changes: 4 additions & 0 deletions pkg/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
68 changes: 68 additions & 0 deletions pkg/configSync.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading