From 5c7df72d1c4549fb738b23e660e4cab6b9fe3ec9 Mon Sep 17 00:00:00 2001 From: Florian Thienel Date: Fri, 8 Jul 2022 19:49:33 +0200 Subject: [PATCH] add support for concatenated text messages with UDH --- README.md | 2 +- sds/sds.go | 137 +++++++++++++++++++++++++++++++++++++++++++++-- sds/sds_test.go | 40 ++++++++++++-- sds/text.go | 34 ++++++++++++ sds/text_test.go | 110 +++++++++++++++++++++++++++++++++++++ 5 files changed, 314 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 2798141..4add675 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ The tetra-pei library allows to communicate with a TETRA radio terminal through its peripheral equipment interface (PEI), with the main focus on simple radio control and the handling of SDS messages. This implementation is solely based on the relevant ETSI specifications: -* ETSI TS 100 392-2 V2.9.2 (2020-06): Terrestrial Trunked Radio (TETRA); Voice plus Data (V+D); Part 2: Air Interface (AI) +* ETSI TS 100 392-2 V3.9.2 (2020-06): Terrestrial Trunked Radio (TETRA); Voice plus Data (V+D); Part 2: Air Interface (AI) * ETSI EN 300 392-5 V2.7.1 (2020-04): Terrestrial Trunked Radio (TETRA); Voice plus Data (V+D) and Direct Mode Operation (DMO); Part 5: Peripheral Equipment Interface (PEI) ## Restrictions diff --git a/sds/sds.go b/sds/sds.go index ef11e04..a2c95fd 100644 --- a/sds/sds.go +++ b/sds/sds.go @@ -122,6 +122,11 @@ func (p ProtocolIdentifier) Encode(bytes []byte, bits int) ([]byte, int) { return append(bytes, byte(p)), bits + 8 } +// Length of this protocol identifier in bytes. +func (p ProtocolIdentifier) Length() int { + return 1 +} + // All protocol identifiers relevant for SDS handling, according to [AI] table 29.21 const ( SimpleTextMessaging ProtocolIdentifier = 0x02 @@ -381,6 +386,72 @@ func NewTextMessageTransfer(messageReference MessageReference, immediate bool, d } } +// NewConcatenatedMessageTransfer returns a set of SDS_TRANSFER PDUs for that make up the given text using concatenated text messages with a UDH. +func NewConcatenatedMessageTransfer(messageReference MessageReference, deliveryReport DeliveryReportRequest, encoding TextEncoding, maxPDUBits int, text string) []SDSTransfer { + blueprint := SDSTransfer{ + protocol: UserDataHeaderMessaging, + MessageReference: messageReference, + DeliveryReportRequest: deliveryReport, + UserData: ConcatenatedTextSDU{ + TextSDU: TextSDU{ + TextHeader: TextHeader{ + Encoding: encoding, + }, + Text: "", + }, + UserDataHeader: ConcatenatedTextUDH{ + ElementID: ConcatenatedTextMessageWithShortReference, + MessageReference: uint16(messageReference), + TotalNumber: 0, + SequenceNumber: 0, + }, + }, + } + blueprintBits := blueprint.Length() * 8 + + textParts := SplitToMaxBits(encoding, maxPDUBits-blueprintBits, text) + + if len(textParts) == 1 { + return []SDSTransfer{{ + protocol: TextMessaging, + MessageReference: messageReference, + DeliveryReportRequest: deliveryReport, + UserData: TextSDU{ + TextHeader: TextHeader{ + Encoding: encoding, + }, + Text: text, + }, + }} + } + + result := make([]SDSTransfer, len(textParts)) + for i, textPart := range textParts { + result[i] = SDSTransfer{ + protocol: UserDataHeaderMessaging, + ServiceSelectionShortFormReport: true, + MessageReference: messageReference + MessageReference(i), + DeliveryReportRequest: deliveryReport, + UserData: ConcatenatedTextSDU{ + TextSDU: TextSDU{ + TextHeader: TextHeader{ + Encoding: encoding, + }, + Text: textPart, + }, + UserDataHeader: ConcatenatedTextUDH{ + ElementID: ConcatenatedTextMessageWithShortReference, + MessageReference: uint16(messageReference), + TotalNumber: byte(len(textParts)), + SequenceNumber: byte(i + 1), + }, + }, + } + } + + return result +} + // SDSTransfer represents the SDS-TRANSFER PDU contents as defined in [AI] 29.4.2.4 type SDSTransfer struct { protocol ProtocolIdentifier @@ -410,12 +481,27 @@ func (m SDSTransfer) Encode(bytes []byte, bits int) ([]byte, int) { case TextSDU: bytes, bits = sdu.Encode(bytes, bits) case ConcatenatedTextSDU: - // bytes, bits = sdu.Encode(bytes, bits) + bytes, bits = sdu.Encode(bytes, bits) } return bytes, bits } +// Length of this SDS-TRANSFER in bytes. +func (m SDSTransfer) Length() int { + var result int + result += m.protocol.Length() + result++ // byte1 + result++ // message reference + switch sdu := m.UserData.(type) { + case TextSDU: + result += sdu.Length() + case ConcatenatedTextSDU: + result += sdu.Length() + } + return result +} + // ReceivedReportRequested indicates if for this SDS-TRANSFER PDU a delivery report is requested for receipt func (m SDSTransfer) ReceivedReportRequested() bool { return m.DeliveryReportRequest == MessageReceivedReportRequested || @@ -857,7 +943,7 @@ func ParseConcatenatedTextSDU(bytes []byte) (ConcatenatedTextSDU, error) { 00: Message Type[4], Delivery Report Request[2], Service Selection/Short form report[1], Store/forward control[1] C9: Message Reference[8] (0xC9) <-- This one is incremented for each part of the concatenated message - 8D: Timestamp Used[1] (yes), Text Encoding Scheme[7] (ISO8859-15/Latin 0 ???) + 8D: Timestamp Used[1] (yes), Text Encoding Scheme[7] (ISO8859-15/Latin 0) 04 5A 8F: Timestamp[24] 05: User Data Header length[8] (5) 00: UDH Information Element ID[8] (0) @@ -901,6 +987,15 @@ type ConcatenatedTextSDU struct { UserDataHeader ConcatenatedTextUDH } +// Encode this concatenated text SDU +func (t ConcatenatedTextSDU) Encode(bytes []byte, bits int) ([]byte, int) { + bytes, bits = t.TextHeader.Encode(bytes, bits) + bytes, bits = t.UserDataHeader.Encode(bytes, bits) + bytes, bits = AppendEncodedPayloadText(bytes, bits, t.Text, t.TextHeader.Encoding) + + return bytes, bits +} + // Length returns the length of this encoded concatenated text SDU in bytes. func (t ConcatenatedTextSDU) Length() int { return t.TextSDU.Length() + t.UserDataHeader.Length() @@ -949,9 +1044,45 @@ type ConcatenatedTextUDH struct { SequenceNumber byte } +// Encode this concatenated text UDH +func (h ConcatenatedTextUDH) Encode(bytes []byte, bits int) ([]byte, int) { + headerLengthIndex := len(bytes) + bytes = append(bytes, 0) + bits += 8 + + bytes = append(bytes, byte(h.ElementID)) + bits += 8 + + elementLengthIndex := len(bytes) + bytes = append(bytes, 0) + bits += 8 + + bytes = append(bytes, byte(h.MessageReference)) + bits += 8 + if h.ElementID == ConcatenatedTextMessageWithLongReference { + bytes = append(bytes, byte(h.MessageReference>>8)) + bits += 8 + } + + bytes = append(bytes, h.TotalNumber) + bits += 8 + bytes = append(bytes, h.SequenceNumber) + bits += 8 + + bytes[headerLengthIndex] = byte(len(bytes) - headerLengthIndex - 1) + bytes[elementLengthIndex] = byte(len(bytes) - elementLengthIndex - 1) + + return bytes, bits +} + // Length returns the length of this header in bytes. func (h ConcatenatedTextUDH) Length() int { - return int(h.HeaderLength) + 1 // the HeaderLength byte itself + result := 6 + if h.ElementID == ConcatenatedTextMessageWithLongReference { + result++ + } + + return result } // UDHInformationElementID enum according to [AI] 29.5.9.4.1 diff --git a/sds/sds_test.go b/sds/sds_test.go index 56eaf6c..a5ab22c 100644 --- a/sds/sds_test.go +++ b/sds/sds_test.go @@ -9,6 +9,7 @@ import ( ) func TestParseMessage(t *testing.T) { + expectedTimestamp := time.Date(time.Now().Year(), time.April, 11, 10, 15, 0, 0, time.Local) tt := []struct { desc string header string @@ -143,7 +144,7 @@ func TestParseMessage(t *testing.T) { UserData: TextSDU{ TextHeader: TextHeader{ Encoding: ISO8859_1, - Timestamp: time.Date(2021, time.April, 11, 10, 15, 0, 0, time.Local), + Timestamp: expectedTimestamp, }, Text: "testmessage", }, @@ -163,7 +164,7 @@ func TestParseMessage(t *testing.T) { TextSDU: TextSDU{ TextHeader: TextHeader{ Encoding: ISO8859_1, - Timestamp: time.Date(2021, time.April, 11, 10, 15, 0, 0, time.Local), + Timestamp: expectedTimestamp, }, Text: "testmessage", }, @@ -192,7 +193,7 @@ func TestParseMessage(t *testing.T) { TextSDU: TextSDU{ TextHeader: TextHeader{ Encoding: ISO8859_1, - Timestamp: time.Date(2021, time.April, 11, 10, 15, 0, 0, time.Local), + Timestamp: expectedTimestamp, }, Text: "testmessage", }, @@ -446,6 +447,7 @@ func TestParseHeader(t *testing.T) { } func TestEncode(t *testing.T) { + expectedTimestamp := time.Date(time.Now().Year(), time.April, 11, 8, 15, 0, 0, time.UTC) tt := []struct { desc string values []Encoder @@ -484,13 +486,13 @@ func TestEncode(t *testing.T) { UserData: TextSDU{ TextHeader: TextHeader{ Encoding: ISO8859_1, - Timestamp: time.Date(2021, time.April, 11, 10, 15, 0, 0, time.UTC), + Timestamp: expectedTimestamp, }, Text: "testmessage", }, }, }, - expectedBytes: []byte{0x82, 0x06, 0xC9, 0x81, 0x44, 0x5A, 0x8F, 0x74, 0x65, 0x73, 0x74, 0x6D, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65}, + expectedBytes: []byte{0x82, 0x06, 0xC9, 0x81, 0x44, 0x5A, 0x0F, 0x74, 0x65, 0x73, 0x74, 0x6D, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65}, expectedBits: 144, }, { @@ -505,6 +507,34 @@ func TestEncode(t *testing.T) { expectedBytes: []byte{0x02, 0x01, 0x74, 0x65, 0x73, 0x74, 0x6D, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65}, expectedBits: 104, }, + { + desc: "SDS-TRANSFER concatenated text message with UDH", + values: []Encoder{ + SDSTransfer{ + protocol: UserDataHeaderMessaging, + MessageReference: 0xC9, + UserData: ConcatenatedTextSDU{ + TextSDU: TextSDU{ + TextHeader: TextHeader{ + Encoding: ISO8859_1, + Timestamp: expectedTimestamp, + }, + Text: "testmessage", + }, + UserDataHeader: ConcatenatedTextUDH{ + HeaderLength: 5, + ElementID: 0, + ElementLength: 3, + MessageReference: 0xC9, + TotalNumber: 2, + SequenceNumber: 1, + }, + }, + }, + }, + expectedBytes: []byte{0x8A, 0x02, 0xC9, 0x81, 0x44, 0x5A, 0x0F, 0x05, 0x00, 0x03, 0xC9, 0x02, 0x01, 0x74, 0x65, 0x73, 0x74, 0x6D, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65}, + expectedBits: 192, + }, } for _, tc := range tt { t.Run(tc.desc, func(t *testing.T) { diff --git a/sds/text.go b/sds/text.go index 454002d..081e958 100644 --- a/sds/text.go +++ b/sds/text.go @@ -124,6 +124,40 @@ func TextBytesToBits(encoding TextEncoding, length int) int { } } +// BitsToTextBytes returns the number of bytes of a text that fit into the given number of bits using the given encoding +func BitsToTextBytes(encoding TextEncoding, bits int) int { + switch encoding { + case Packed7Bit: + return bits / 7 + default: + return bits / 8 + } +} + +// SplitToMaxBits splits the given text into parts that do not exceed the given maximum number of bits using the given encoding +func SplitToMaxBits(encoding TextEncoding, maxPDUBits int, text string) []string { + if text == "" { + return []string{} + } + + maxPartLength := BitsToTextBytes(encoding, maxPDUBits) + maxPartsCount := len(text)/maxPartLength + 1 + result := make([]string, 0, maxPartsCount) + + remainingText := text + for len(remainingText) > maxPartLength { + part := remainingText[0:maxPartLength] + remainingText = remainingText[maxPartLength:] + if part != "" { + result = append(result, part) + } + } + if len(remainingText) > 0 { + result = append(result, remainingText) + } + return result +} + // ParseTextHeader in text messages and concatenated text messages. func ParseTextHeader(bytes []byte) (TextHeader, error) { if len(bytes) < 1 { diff --git a/sds/text_test.go b/sds/text_test.go index ba12ae8..870b2af 100644 --- a/sds/text_test.go +++ b/sds/text_test.go @@ -6,6 +6,116 @@ import ( "github.com/stretchr/testify/assert" ) +func TestBitsToTextBytes(t *testing.T) { + tt := []struct { + desc string + encoding TextEncoding + bits int + expectedBytes int + }{ + { + desc: "7bit, 0", + encoding: Packed7Bit, + bits: 0, + expectedBytes: 0, + }, + { + desc: "8bit, 0", + encoding: ISO8859_1, + bits: 0, + expectedBytes: 0, + }, + { + desc: "7bit, 1", + encoding: Packed7Bit, + bits: 1, + expectedBytes: 0, + }, + { + desc: "8bit, 1", + encoding: ISO8859_1, + bits: 1, + expectedBytes: 0, + }, + { + desc: "7bit, 8", + encoding: Packed7Bit, + bits: 8, + expectedBytes: 1, + }, + { + desc: "7bit, 14", + encoding: Packed7Bit, + bits: 14, + expectedBytes: 2, + }, + { + desc: "8bit, 14", + encoding: ISO8859_1, + bits: 14, + expectedBytes: 1, + }, + { + desc: "7bit, 56", + encoding: Packed7Bit, + bits: 56, + expectedBytes: 8, + }, + { + desc: "8bit, 56", + encoding: ISO8859_1, + bits: 56, + expectedBytes: 7, + }, + } + for _, tc := range tt { + t.Run(tc.desc, func(t *testing.T) { + actualBytes := BitsToTextBytes(tc.encoding, tc.bits) + assert.Equal(t, tc.expectedBytes, actualBytes) + }) + } +} + +func TestSplitToMaxBits(t *testing.T) { + tt := []struct { + encoding TextEncoding + maxPDUBits int + text string + expectedParts []string + }{ + { + encoding: Packed7Bit, + maxPDUBits: 56, + text: "7-bit, 056", + expectedParts: []string{"7-bit, 0", "56"}, + }, + { + encoding: ISO8859_1, + maxPDUBits: 56, + text: "8-bit, 056", + expectedParts: []string{"8-bit, ", "056"}, + }, + { + encoding: Packed7Bit, + maxPDUBits: 128, + text: "7-bit, 128", + expectedParts: []string{"7-bit, 128"}, + }, + { + encoding: ISO8859_1, + maxPDUBits: 128, + text: "8-bit, 128", + expectedParts: []string{"8-bit, 128"}, + }, + } + for _, tc := range tt { + t.Run(tc.text, func(t *testing.T) { + actualParts := SplitToMaxBits(tc.encoding, tc.maxPDUBits, tc.text) + assert.Equal(t, tc.expectedParts, actualParts) + }) + } +} + func TestSplitLeadingOPTA(t *testing.T) { tt := []struct { desc string