DMR text messages (SMS): ETSI/Motorola-TMS messaging on the radio

Full messaging support, implemented fresh in three layers:

- dmrDataProtocol: pure short-data codec (CSBK preamble, data/response
  headers, IP/UDP/TMS payload, ETSI CRCs) validated against on-air
  captures by a host-side test suite. Transmits Motorola TMS (UDP 4007),
  receives TMS and Anytone/"DMR standard" (UDP 5016).
- smsCore/smsStorage: TX queue with wait-for-channel and delivery
  confirmation (response PDU / ACK, resend prompt on timeout), ISR-side
  block assembly with layout auto-detection (confirmed/unconfirmed,
  rate-1/2 and full 18-byte rate-3/4 blocks), delivery reports to
  senders that request them, and a checksummed message store in SPI
  flash at 0xC00000 (inbox + sent, 8 each; 8 quick texts).
- UI: Messages hub (main menu or long-press GREEN), keypad compose with
  multi-tap (160 chars), inbox/sent/view with reply-resend-delete,
  quick-text picker/editor, options (Wait for ACK, My ID only), and an
  incoming-message popup with chime. Strings in all 20 languages.

The HR-C6000 does the BPTC/FEC; the driver streams 12-byte logical
bursts through the existing TX state machine (repeater wake included),
locks out PTT during a send, and leaves hotspot mode untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Marcus Kida 2026-07-13 16:52:54 +02:00
parent 58e8269500
commit 31c31df861
43 changed files with 3880 additions and 11 deletions

View file

@ -7,6 +7,19 @@ single running list until the first versioned release.
## Unreleased
- **DMR text messages (SMS)** — full ETSI/Motorola-TMS-compatible messaging:
compose on the keypad (multi-tap, up to 160 characters), inbox and sent box
(8 messages each, persisted to flash across power cycles), 10 editable
quick-text templates, an incoming-message popup with chime and one-key
reply, and delivery confirmation (waits for the ETSI response PDU / ACK
with a resend prompt on timeout). Transmits in the Motorola TMS format
(UDP port 4007) understood by BrandMeister, hotspots and most OEM radios;
receives both TMS and the Anytone/"DMR standard" format (port 5016) and
answers delivery-report requests from other radios. Reachable from the
main menu ("Messages") or a **long-press on GREEN** in channel/VFO mode.
Options: "Wait for ACK" and "My ID only" (accept only messages addressed
to your own DMR ID). Voice PTT is locked out while a message transmits,
and hotspot mode is unaffected.
- **Removed the main-menu icons** — the top-level menu entries are plain text again.
## 0.6.0

View file

@ -37,6 +37,7 @@ key), the **top / orange** button, and a keypad with **green** (✓ / menu), **r
| **SK1 + SK2** | Reverse repeater — ⚪ [OpenGD77] |
| **`#`** (hold, VFO) | Toggle the **spectrum scope** — ⚪ [OpenGD77] |
| **SK2 + green** (Channel) | Open **Channel Details** editor — ⚪ [OpenGD77] |
| **Green** (hold) | Open **Messages** (DMR SMS) — 🟢 [FreeTRX] |
---
@ -155,6 +156,37 @@ FreeTRX resolves a hard-fault (freeze needing a battery pull) on the DMR path th
affected recent self-built OpenGD77 revisions — the codec is called through C
function pointers instead of inline assembler that modern GCC miscompiled.
### Text messages (SMS) — 🟢 [FreeTRX]
Send and receive DMR text messages, compatible with BrandMeister, hotspots and
OEM radios (Motorola TMS format on transmit; TMS **and** Anytone/"DMR standard"
decoded on receive).
**Open it** from **Menu → Messages**, or **hold GREEN** on the channel/VFO
screen. The hub offers *New message*, *Inbox*, *Sent*, *Quick text* and
*Options*.
**Composing:** enter the destination DMR ID with the number keys (**UP/DOWN**
switches between the ID and text fields), type up to 160 characters with
multi-tap on the keypad (**LEFT/RIGHT** move the cursor, **SK2+LEFT** deletes),
then press **GREEN** to send. Sending only works on a DMR channel; progress is
shown as notifications ("Sending", "Sent", "Delivered"). With **Wait for ACK**
enabled (default), a missing delivery report pops up a **resend** prompt.
**Receiving:** a popup with a chime shows the sender and a preview —
**GREEN** opens the message, **SK2+GREEN** starts a reply, **RED** dismisses.
The inbox and sent box keep the last 8 messages each and survive power
cycles; unread messages are marked `*`. In a message view, **GREEN**
replies (inbox) or re-edits/resends (sent), **SK2+RED** deletes. The radio
automatically answers delivery-report requests from other radios.
**Quick texts:** 10 editable templates (**GREEN** uses one in a new message,
**SK2+GREEN** edits, **SK2+RED** deletes).
**Options:** *Wait for ACK* (delivery confirmation) and *My ID only*
(ignore messages not addressed to your DMR ID). PTT is locked out for the
second or two a message is on air; hotspot mode is unaffected.
---
## About & versioning — 🟢 [FreeTRX]

View file

@ -0,0 +1,136 @@
/*
* Copyright (C) 2026-today Marcus Kida, DK1DA
*
*
* Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions
* are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer
* in the documentation and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived
* from this software without specific prior written permission.
*
* 4. Use of this source code or binary releases for commercial purposes is strictly forbidden. This includes, without limitation,
* incorporation in a commercial product or incorporation into a product or project which allows commercial use.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
*/
/*
* DMR short-data (SMS) frame and payload codec.
*
* Pure functions only: no hardware access, no OS calls, no mutable globals,
* so the whole module can be compiled and unit-tested on the host.
*
* The HR-C6000 baseband performs BPTC(196,96)/FEC and the on-air CRC in
* hardware; this module only deals with the 12-byte logical payload of each
* burst (CSBK preamble, data header, rate-1/2 data block) and with the
* IP/UDP-encapsulated text payload spread across the data blocks.
*
* Message layouts follow ETSI TS 102 361-1/-2 plus the two de-facto text
* message formats: Motorola TMS (UDP port 4007) and "DMR standard"/Anytone
* (UDP port 5016), as documented in KY4YI's "DMR SMS/Text Message
* Specification rev 1.0".
*/
#ifndef _OPENGD77_DMR_DATA_PROTOCOL_H_
#define _OPENGD77_DMR_DATA_PROTOCOL_H_
#include <stdint.h>
#include <stdbool.h>
#define DMR_DATA_BURST_LENGTH 12 // logical payload bytes per CSBK / header / rate-1/2 block burst
#define DMR_DATA_RATE34_BLOCK_LENGTH 18 // logical payload bytes per rate-3/4 block burst
#define DMR_DATA_CONFIRMED_PREFIX_LENGTH 2 // confirmed-data blocks start with a serial + CRC9 prefix
// DMR data types (high nibble of HR-C6000 register 0x51 on RX / written to register 0x50 on TX)
#define DMR_DATA_TYPE_CSBK 0x03
#define DMR_DATA_TYPE_DATA_HEADER 0x06
#define DMR_DATA_TYPE_RATE_12_DATA 0x07
#define DMR_DATA_TYPE_RATE_34_DATA 0x08
// SMS text limits. We compose up to 160 characters (in line with typical
// OEM radio limits): 38 (IP+UDP+TMS headers) + 320 + 4 (CRC32) = 362 bytes
// => 31 rate-1/2 blocks. On receive, up to 42 blocks are accepted so that
// longer messages from other vendors still decode (text beyond
// SMS_MAX_TEXT_LENGTH is truncated).
#define SMS_MAX_TEXT_LENGTH 160
#define SMS_MAX_TX_DATA_BLOCKS 31
#define SMS_MAX_DATA_BLOCKS 42
#define SMS_MAX_PAYLOAD_LENGTH (SMS_MAX_DATA_BLOCKS * DMR_DATA_BURST_LENGTH)
#define SMS_UDP_PORT_MOTOROLA 0x0FA7 // 4007
#define SMS_UDP_PORT_STANDARD 0x1398 // 5016
#define SMS_TEXT_OFFSET_MOTOROLA 38 // IP(20) + UDP(8) + TMS header(10)
#define SMS_TEXT_OFFSET_STANDARD 32 // IP(20) + UDP(8) + vendor header(4)
typedef enum
{
SMS_ENCODE_OK = 0,
SMS_ENCODE_EMPTY,
SMS_ENCODE_TOO_LONG
} smsEncodeResult_t;
typedef struct
{
uint32_t srcId;
uint32_t dstId;
uint8_t dpf; // Data Packet Format (0x01 response, 0x02 unconfirmed, 0x03 confirmed)
uint8_t sap; // Service Access Point (0x04 = UDP/IP)
uint8_t blockCount; // blocks to follow
uint8_t padOctets;
bool isGroup;
bool responseRequested;
bool isResponsePdu; // delivery confirmation (DPF 0x01, no payload blocks)
} dmrDataHeader_t;
//
// Checksums
//
uint16_t dmrDataCRC16(const uint8_t *data, uint16_t length); // CRC16-CCITT, poly 0x1021, init 0x0000 (unmasked)
uint32_t dmrDataCRC32(const uint8_t *data, uint16_t length); // ETSI DMR CRC32, input taken as byte-swapped 16-bit pairs
uint16_t dmrDataIPHeaderChecksum(const uint8_t *ipHeader); // RFC 791, over 20 bytes with checksum field zeroed
uint16_t dmrDataUDPChecksum(const uint8_t *ipPacket, uint16_t udpLength); // RFC 768, with IPv4 pseudo header
//
// 12-byte burst payload builders (CRC computed and masked internally)
//
void dmrDataBuildPreambleCSBK(uint8_t frame[DMR_DATA_BURST_LENGTH], uint32_t dstId, uint32_t srcId, uint8_t blocksToFollow);
void dmrDataBuildUnconfirmedHeader(uint8_t frame[DMR_DATA_BURST_LENGTH], uint32_t dstId, uint32_t srcId,
uint8_t blockCount, uint8_t padOctets, bool requestResponse);
void dmrDataBuildResponseHeader(uint8_t frame[DMR_DATA_BURST_LENGTH], uint32_t dstId, uint32_t srcId, bool repeaterProfile);
//
// 12-byte burst payload parser
//
bool dmrDataParseHeader(const uint8_t frame[DMR_DATA_BURST_LENGTH], dmrDataHeader_t *header);
//
// Text payload (IP/UDP encapsulation spread over the data blocks)
//
// Builds the complete Motorola TMS payload: IP + UDP + TMS headers, UTF-16LE
// text, zero padding to a 12-byte boundary and trailing CRC32. On success
// *payloadLength is a multiple of 12 and *padOctets is the number of zero
// octets between the text and the CRC32 trailer.
smsEncodeResult_t smsPayloadBuildMotorola(uint8_t *payload, uint16_t *payloadLength, uint8_t *padOctets,
uint32_t dstId, uint32_t srcId, const char *text, uint16_t ipSequenceNumber);
// Strict parsers for the two known formats. textOut is NUL terminated ASCII
// (non-representable characters become '?').
bool smsPayloadParseMotorola(const uint8_t *payload, uint16_t payloadLength, char *textOut, uint16_t textOutSize);
bool smsPayloadParseStandard(const uint8_t *payload, uint16_t payloadLength, char *textOut, uint16_t textOutSize);
// Lenient fallback: find the longest plausible UTF-16 printable run anywhere
// in the payload (tries little-endian first, then big-endian). Returns false
// when nothing readable (>= 4 characters) was found.
bool smsPayloadScanText(const uint8_t *payload, uint16_t payloadLength, char *textOut, uint16_t textOutSize);
#endif /* _OPENGD77_DMR_DATA_PROTOCOL_H_ */

View file

@ -0,0 +1,114 @@
/*
* Copyright (C) 2026-today Marcus Kida, DK1DA
*
*
* Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions
* are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer
* in the documentation and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived
* from this software without specific prior written permission.
*
* 4. Use of this source code or binary releases for commercial purposes is strictly forbidden. This includes, without limitation,
* incorporation in a commercial product or incorporation into a product or project which allows commercial use.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
*/
/*
* DMR SMS engine: outgoing message queue and delivery tracking, incoming
* message assembly and decoding, and delivery-report (ACK) handling.
*
* Context rules:
* - smsRxProcessDataFrame() and the smsTxNotify*() functions are called from
* the HR-C6000 interrupt handlers only.
* - Everything else runs in the main application task. Decoding, storage
* and event delivery are deferred from ISR to smsCoreTick().
*/
#ifndef _OPENGD77_SMS_CORE_H_
#define _OPENGD77_SMS_CORE_H_
#include <stdint.h>
#include <stdbool.h>
#include "functions/dmrDataProtocol.h"
#define SMS_TX_PREAMBLE_COUNT 8
#define SMS_TX_ACK_PREAMBLE_COUNT 24
// large enough for a full message (8 preambles + header + 31 blocks) and
// for a delivery report (24 preambles + header, no blocks)
#define SMS_TX_MAX_FRAMES (SMS_TX_PREAMBLE_COUNT + 1 + SMS_MAX_TX_DATA_BLOCKS)
typedef enum
{
SMS_TX_STATE_IDLE = 0,
SMS_TX_STATE_WAIT_CHANNEL, // packed, waiting for the channel to become free
SMS_TX_STATE_SENDING, // bursts going out
SMS_TX_STATE_WAIT_ACK // sent, waiting for a delivery report
} smsTxState_t;
typedef enum
{
SMS_EVENT_NONE = 0,
SMS_EVENT_TX_SENDING,
SMS_EVENT_TX_SENT,
SMS_EVENT_TX_DELIVERED,
SMS_EVENT_TX_TIMEOUT, // no delivery report received
SMS_EVENT_TX_CHANNEL_BUSY,
SMS_EVENT_RX_MESSAGE
} smsEvent_t;
// One prepared over-the-air transmission: preambleCount CSBK frames, then
// the data header, then the payload blocks. The per-frame DMR data type
// follows from the frame index.
typedef struct
{
uint8_t frameCount;
uint8_t preambleCount;
uint8_t frames[SMS_TX_MAX_FRAMES][DMR_DATA_BURST_LENGTH];
} smsAirJob_t;
//
// Main task context
//
void smsCoreInit(void);
void smsCoreTick(void);
bool smsCoreIsIdle(void); // nothing in flight (used to gate flash writes)
bool smsSendMessage(uint32_t dstId, const char *text);
bool smsResendLast(void);
bool smsCanResendLast(void);
smsTxState_t smsGetTxState(void);
smsEvent_t smsPopEvent(void);
bool smsHasUnseenRxMessage(void);
void smsAcknowledgeRxMessage(void);
bool smsOptionWaitForAck(void);
void smsOptionSetWaitForAck(bool waitForAck);
bool smsOptionOwnIdOnly(void);
void smsOptionSetOwnIdOnly(bool ownIdOnly);
//
// HR-C6000 driver interface (ISR context)
//
// frame carries the burst's logical payload: 18 bytes for rate-3/4 blocks,
// 12 bytes for everything else.
bool smsRxProcessDataFrame(uint8_t dataType, const uint8_t *frame);
void smsTxNotifyAirDone(void);
void smsTxNotifyRejected(void);
void smsTxNotifyHwAck(void);
const smsAirJob_t *smsGetPendingAirJob(void);
void smsAirJobStarted(void);
#endif /* _OPENGD77_SMS_CORE_H_ */

View file

@ -0,0 +1,91 @@
/*
* Copyright (C) 2026-today Marcus Kida, DK1DA
*
*
* Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions
* are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer
* in the documentation and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived
* from this software without specific prior written permission.
*
* 4. Use of this source code or binary releases for commercial purposes is strictly forbidden. This includes, without limitation,
* incorporation in a commercial product or incorporation into a product or project which allows commercial use.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
*/
/*
* Persistent store for the DMR SMS feature: inbox, sent messages and
* quick-text templates, held as a single RAM image and lazily persisted
* to a dedicated SPI flash region.
*
* Physical flash map on this platform (16MB W25Q128):
* 0x000000 - 0x00FFFF emulated EEPROM (settings)
* 0x010000 calibration copy
* 0x020000 - ~0x100000 codeplug (FLASH_ADDRESS_OFFSET 128KB + codeplug regions)
* 0x050000 / 0x0D8000 DMR ID databases
* 0x0AF400 / 0x100000 voice prompts
* 0xC00000 << SMS store (this module, 2 sectors) >>
* 0xE00000 - 0xFFFFFF GPS log (last 2MB)
*/
#ifndef _OPENGD77_SMS_STORAGE_H_
#define _OPENGD77_SMS_STORAGE_H_
#include <stdint.h>
#include <stdbool.h>
#include "functions/dmrDataProtocol.h"
#define SMS_STORAGE_FLASH_ADDRESS 0xC00000
#define SMS_INBOX_SLOTS 8
#define SMS_SENT_SLOTS 8
#define SMS_QUICKTEXT_SLOTS 8
#define SMS_RECORD_FLAG_UNREAD 0x01
// smsStorageOptions bits
#define SMS_OPTION_WAIT_FOR_ACK 0x01
#define SMS_OPTION_OWN_ID_ONLY 0x02
typedef struct
{
uint32_t peerId; // sender (inbox) or destination (sent)
uint8_t flags;
char text[SMS_MAX_TEXT_LENGTH + 1];
} smsRecord_t;
void smsStorageInit(void);
void smsStorageTick(void); // debounced persist, main loop context only
void smsStorageMarkDirty(void);
uint8_t smsInboxCount(void);
const smsRecord_t *smsInboxGet(uint8_t index); // index 0 = newest
bool smsInboxAdd(uint32_t srcId, const char *text); // newest-first, oldest dropped when full
bool smsInboxDelete(uint8_t index);
void smsInboxMarkRead(uint8_t index);
uint8_t smsInboxUnreadCount(void);
uint8_t smsSentCount(void);
const smsRecord_t *smsSentGet(uint8_t index);
bool smsSentAdd(uint32_t dstId, const char *text);
bool smsSentDelete(uint8_t index);
uint8_t smsQuickTextCount(void);
const char *smsQuickTextGet(uint8_t index);
bool smsQuickTextSet(uint8_t index, const char *text); // index == smsQuickTextCount() appends
bool smsQuickTextDelete(uint8_t index);
uint8_t smsStorageGetOptions(void);
void smsStorageSetOptions(uint8_t options);
#endif /* _OPENGD77_SMS_STORAGE_H_ */

View file

@ -39,6 +39,7 @@ extern Task_t beepTask;
extern int16_t melody_generic[512];
extern const int16_t MELODY_POWER_ON[];
extern const int16_t MELODY_PRIVATE_CALL[];
extern const int16_t MELODY_SMS_RX[];
extern const int16_t MELODY_KEY_BEEP[];
extern const int16_t MELODY_KEY_LONG_BEEP[];
extern const int16_t MELODY_ACK_BEEP[];

View file

@ -102,6 +102,9 @@ void HRC6000ClearColorCodeSynchronisation(void);
void HRC6000SetTalkerAlias(const char *text);
void HRC6000SetTalkerAliasLocation(uint32_t Lat, uint32_t Lon);
bool HRC6000IRQHandlerIsRunning(void);
bool HRC6000StartSmsTransmission(void);
bool HRC6000IsSendingSMS(void);
void HRC6000CancelSmsTransmission(void);
bool HRC6000HasGotSync(void);
bool HRC6000CCIsHeld(void);
void HRC6000SetDmrRxGain(int8_t gain);

View file

@ -329,6 +329,22 @@ const stringsTable_t catalanLanguage=
.mute = "Mute", // MaxLen: 16 (with ':' + .on or .off)
.delete_channel = "Esborra canal", // MaxLen: 16
.keep_one_channel = "Deixa 1 canal", // MaxLen: 16
.messages = "Messages", // MaxLen: 16
.new_message = "New message", // MaxLen: 16
.inbox = "Inbox", // MaxLen: 16
.sent_box = "Sent", // MaxLen: 16
.quick_text = "Quick text", // MaxLen: 16
.send_to = "Send to", // MaxLen: 16
.sms_sending = "Sending", // MaxLen: 16
.sms_sent = "Sent", // MaxLen: 16
.sms_delivered = "Delivered", // MaxLen: 16
.sms_no_ack = "No ACK", // MaxLen: 16
.sms_resend = "Resend?", // MaxLen: 16
.sms_channel_busy = "Channel busy", // MaxLen: 16
.sms_wait_for_ack = "Wait for ACK", // MaxLen: 16
.sms_own_id_only = "My ID only", // MaxLen: 16
.message_from = "Message from", // MaxLen: 16
.delete_message = "Delete message?", // MaxLen: 16
};
/********************************************************************
*

View file

@ -329,6 +329,22 @@ const stringsTable_t croatianLanguage =
.mute = "Mute", // MaxLen: 16 (with ':' + .on or .off)
.delete_channel = "Delete Channel", // MaxLen: 16
.keep_one_channel = "Keep 1 channel", // MaxLen: 16
.messages = "Messages", // MaxLen: 16
.new_message = "New message", // MaxLen: 16
.inbox = "Inbox", // MaxLen: 16
.sent_box = "Sent", // MaxLen: 16
.quick_text = "Quick text", // MaxLen: 16
.send_to = "Send to", // MaxLen: 16
.sms_sending = "Sending", // MaxLen: 16
.sms_sent = "Sent", // MaxLen: 16
.sms_delivered = "Delivered", // MaxLen: 16
.sms_no_ack = "No ACK", // MaxLen: 16
.sms_resend = "Resend?", // MaxLen: 16
.sms_channel_busy = "Channel busy", // MaxLen: 16
.sms_wait_for_ack = "Wait for ACK", // MaxLen: 16
.sms_own_id_only = "My ID only", // MaxLen: 16
.message_from = "Message from", // MaxLen: 16
.delete_message = "Delete message?", // MaxLen: 16
};
/********************************************************************
*

View file

@ -329,6 +329,22 @@ const stringsTable_t czechLanguage =
.mute = "Mute", // MaxLen: 16 (with ':' + .on or .off)
.delete_channel = "Smazat kanál", // MaxLen: 16
.keep_one_channel = "Nech 1 kanál", // MaxLen: 16
.messages = "Messages", // MaxLen: 16
.new_message = "New message", // MaxLen: 16
.inbox = "Inbox", // MaxLen: 16
.sent_box = "Sent", // MaxLen: 16
.quick_text = "Quick text", // MaxLen: 16
.send_to = "Send to", // MaxLen: 16
.sms_sending = "Sending", // MaxLen: 16
.sms_sent = "Sent", // MaxLen: 16
.sms_delivered = "Delivered", // MaxLen: 16
.sms_no_ack = "No ACK", // MaxLen: 16
.sms_resend = "Resend?", // MaxLen: 16
.sms_channel_busy = "Channel busy", // MaxLen: 16
.sms_wait_for_ack = "Wait for ACK", // MaxLen: 16
.sms_own_id_only = "My ID only", // MaxLen: 16
.message_from = "Message from", // MaxLen: 16
.delete_message = "Delete message?", // MaxLen: 16
};
/********************************************************************
*

View file

@ -329,6 +329,22 @@ const stringsTable_t danishLanguage =
.mute = "Mute", // MaxLen: 16 (with ':' + .on or .off)
.delete_channel = "Slet kanal", // MaxLen: 16
.keep_one_channel = "Behold 1 kanal", // MaxLen: 16
.messages = "Messages", // MaxLen: 16
.new_message = "New message", // MaxLen: 16
.inbox = "Inbox", // MaxLen: 16
.sent_box = "Sent", // MaxLen: 16
.quick_text = "Quick text", // MaxLen: 16
.send_to = "Send to", // MaxLen: 16
.sms_sending = "Sending", // MaxLen: 16
.sms_sent = "Sent", // MaxLen: 16
.sms_delivered = "Delivered", // MaxLen: 16
.sms_no_ack = "No ACK", // MaxLen: 16
.sms_resend = "Resend?", // MaxLen: 16
.sms_channel_busy = "Channel busy", // MaxLen: 16
.sms_wait_for_ack = "Wait for ACK", // MaxLen: 16
.sms_own_id_only = "My ID only", // MaxLen: 16
.message_from = "Message from", // MaxLen: 16
.delete_message = "Delete message?", // MaxLen: 16
};
/********************************************************************
*

View file

@ -329,6 +329,22 @@ const stringsTable_t dutchLanguage =
.mute = "Mute", // MaxLen: 16 (with ':' + .on or .off)
.delete_channel = "Kanaal wissen", // MaxLen: 16
.keep_one_channel = "Hou 1 kanaal", // MaxLen: 16
.messages = "Berichten", // MaxLen: 16
.new_message = "Nieuw bericht", // MaxLen: 16
.inbox = "Postvak in", // MaxLen: 16
.sent_box = "Verzonden", // MaxLen: 16
.quick_text = "Snelle tekst", // MaxLen: 16
.send_to = "Sturen naar", // MaxLen: 16
.sms_sending = "Verzenden...", // MaxLen: 16
.sms_sent = "Verzonden", // MaxLen: 16
.sms_delivered = "Afgeleverd", // MaxLen: 16
.sms_no_ack = "Geen ACK", // MaxLen: 16
.sms_resend = "Opnieuw?", // MaxLen: 16
.sms_channel_busy = "Kanaal bezet", // MaxLen: 16
.sms_wait_for_ack = "Wacht op ACK", // MaxLen: 16
.sms_own_id_only = "Alleen mijn ID", // MaxLen: 16
.message_from = "Bericht van", // MaxLen: 16
.delete_message = "Bericht wissen?", // MaxLen: 16
};
/********************************************************************
*

View file

@ -329,6 +329,22 @@ const stringsTable_t englishLanguage =
.mute = "Mute", // MaxLen: 16 (with ':' + .on or .off)
.delete_channel = "Delete Channel", // MaxLen: 16
.keep_one_channel = "Keep 1 channel", // MaxLen: 16
.messages = "Messages", // MaxLen: 16
.new_message = "New message", // MaxLen: 16
.inbox = "Inbox", // MaxLen: 16
.sent_box = "Sent", // MaxLen: 16
.quick_text = "Quick text", // MaxLen: 16
.send_to = "Send to", // MaxLen: 16
.sms_sending = "Sending", // MaxLen: 16
.sms_sent = "Sent", // MaxLen: 16
.sms_delivered = "Delivered", // MaxLen: 16
.sms_no_ack = "No ACK", // MaxLen: 16
.sms_resend = "Resend?", // MaxLen: 16
.sms_channel_busy = "Channel busy", // MaxLen: 16
.sms_wait_for_ack = "Wait for ACK", // MaxLen: 16
.sms_own_id_only = "My ID only", // MaxLen: 16
.message_from = "Message from", // MaxLen: 16
.delete_message = "Delete message?", // MaxLen: 16
};
/********************************************************************
*

View file

@ -329,6 +329,22 @@ const stringsTable_t finnishLanguage =
.mute = "Mute", // MaxLen: 16 (with ':' + .on or .off)
.delete_channel = "Poista kanava", // MaxLen: 16
.keep_one_channel = "Pidä 1 kanava", // MaxLen: 16
.messages = "Messages", // MaxLen: 16
.new_message = "New message", // MaxLen: 16
.inbox = "Inbox", // MaxLen: 16
.sent_box = "Sent", // MaxLen: 16
.quick_text = "Quick text", // MaxLen: 16
.send_to = "Send to", // MaxLen: 16
.sms_sending = "Sending", // MaxLen: 16
.sms_sent = "Sent", // MaxLen: 16
.sms_delivered = "Delivered", // MaxLen: 16
.sms_no_ack = "No ACK", // MaxLen: 16
.sms_resend = "Resend?", // MaxLen: 16
.sms_channel_busy = "Channel busy", // MaxLen: 16
.sms_wait_for_ack = "Wait for ACK", // MaxLen: 16
.sms_own_id_only = "My ID only", // MaxLen: 16
.message_from = "Message from", // MaxLen: 16
.delete_message = "Delete message?", // MaxLen: 16
};
/********************************************************************
*

View file

@ -329,6 +329,22 @@ const stringsTable_t frenchLanguage =
.mute = "Sourdine", // MaxLen: 16 (with ':' + .on or .off)
.delete_channel = "Effacer canal", // MaxLen: 16
.keep_one_channel = "Garder 1 canal", // MaxLen: 16
.messages = "Messages", // MaxLen: 16
.new_message = "Nouveau message", // MaxLen: 16
.inbox = "Messages reçus", // MaxLen: 16
.sent_box = "Envoyés", // MaxLen: 16
.quick_text = "Texte rapide", // MaxLen: 16
.send_to = "Envoyer à", // MaxLen: 16
.sms_sending = "Envoi...", // MaxLen: 16
.sms_sent = "Envoyé", // MaxLen: 16
.sms_delivered = "Distribué", // MaxLen: 16
.sms_no_ack = "Pas dACK", // MaxLen: 16
.sms_resend = "Renvoyer ?", // MaxLen: 16
.sms_channel_busy = "Canal occupé", // MaxLen: 16
.sms_wait_for_ack = "Attendre ACK", // MaxLen: 16
.sms_own_id_only = "Mon ID seul", // MaxLen: 16
.message_from = "Message de", // MaxLen: 16
.delete_message = "Suppr. message?", // MaxLen: 16
};
/********************************************************************
*

View file

@ -329,6 +329,22 @@ const stringsTable_t germanLanguage =
.mute = "Mute", // MaxLen: 16 (with ':' + .on or .off)
.delete_channel = "Kanal löschen", // MaxLen: 16
.keep_one_channel = "1 Kanal behalten", // MaxLen: 16
.messages = "Nachrichten", // MaxLen: 16
.new_message = "Neue Nachricht", // MaxLen: 16
.inbox = "Posteingang", // MaxLen: 16
.sent_box = "Gesendet", // MaxLen: 16
.quick_text = "Schnelltext", // MaxLen: 16
.send_to = "Senden an", // MaxLen: 16
.sms_sending = "Sende...", // MaxLen: 16
.sms_sent = "Gesendet", // MaxLen: 16
.sms_delivered = "Zugestellt", // MaxLen: 16
.sms_no_ack = "Kein ACK", // MaxLen: 16
.sms_resend = "Nochmal senden?", // MaxLen: 16
.sms_channel_busy = "Kanal belegt", // MaxLen: 16
.sms_wait_for_ack = "Auf ACK warten", // MaxLen: 16
.sms_own_id_only = "Nur eigene ID", // MaxLen: 16
.message_from = "Nachricht von", // MaxLen: 16
.delete_message = "Nachricht lösch.", // MaxLen: 16
};
/********************************************************************
*

View file

@ -329,6 +329,22 @@ const stringsTable_t hungarianLanguage=
.mute = "Mute", // MaxLen: 16 (with ':' + .on or .off)
.delete_channel = "Delete Channel", // MaxLen: 16
.keep_one_channel = "Keep 1 channel", // MaxLen: 16
.messages = "Messages", // MaxLen: 16
.new_message = "New message", // MaxLen: 16
.inbox = "Inbox", // MaxLen: 16
.sent_box = "Sent", // MaxLen: 16
.quick_text = "Quick text", // MaxLen: 16
.send_to = "Send to", // MaxLen: 16
.sms_sending = "Sending", // MaxLen: 16
.sms_sent = "Sent", // MaxLen: 16
.sms_delivered = "Delivered", // MaxLen: 16
.sms_no_ack = "No ACK", // MaxLen: 16
.sms_resend = "Resend?", // MaxLen: 16
.sms_channel_busy = "Channel busy", // MaxLen: 16
.sms_wait_for_ack = "Wait for ACK", // MaxLen: 16
.sms_own_id_only = "My ID only", // MaxLen: 16
.message_from = "Message from", // MaxLen: 16
.delete_message = "Delete message?", // MaxLen: 16
};
/********************************************************************
*

View file

@ -329,6 +329,22 @@ const stringsTable_t italianLanguage =
.mute = "Muto", // MaxLen: 16 (with ':' + .on or .off)
.delete_channel = "Elimina canale", // MaxLen: 16
.keep_one_channel = "Tieni 1 canale", // MaxLen: 16
.messages = "Messaggi", // MaxLen: 16
.new_message = "Nuovo messaggio", // MaxLen: 16
.inbox = "In arrivo", // MaxLen: 16
.sent_box = "Inviati", // MaxLen: 16
.quick_text = "Testo rapido", // MaxLen: 16
.send_to = "Invia a", // MaxLen: 16
.sms_sending = "Invio...", // MaxLen: 16
.sms_sent = "Inviato", // MaxLen: 16
.sms_delivered = "Consegnato", // MaxLen: 16
.sms_no_ack = "Nessun ACK", // MaxLen: 16
.sms_resend = "Reinviare?", // MaxLen: 16
.sms_channel_busy = "Canale occupato", // MaxLen: 16
.sms_wait_for_ack = "Attendi ACK", // MaxLen: 16
.sms_own_id_only = "Solo mio ID", // MaxLen: 16
.message_from = "Messaggio da", // MaxLen: 16
.delete_message = "Elimina msg?", // MaxLen: 16
};
/********************************************************************
*

View file

@ -329,6 +329,22 @@ const stringsTable_t japaneseLanguage =
.mute = "Mute", // MaxLen: 16 (with ':' + .on or .off)
.delete_channel = "Delete Channel", // MaxLen: 16
.keep_one_channel = "Keep 1 channel", // MaxLen: 16
.messages = "Messages", // MaxLen: 16
.new_message = "New message", // MaxLen: 16
.inbox = "Inbox", // MaxLen: 16
.sent_box = "Sent", // MaxLen: 16
.quick_text = "Quick text", // MaxLen: 16
.send_to = "Send to", // MaxLen: 16
.sms_sending = "Sending", // MaxLen: 16
.sms_sent = "Sent", // MaxLen: 16
.sms_delivered = "Delivered", // MaxLen: 16
.sms_no_ack = "No ACK", // MaxLen: 16
.sms_resend = "Resend?", // MaxLen: 16
.sms_channel_busy = "Channel busy", // MaxLen: 16
.sms_wait_for_ack = "Wait for ACK", // MaxLen: 16
.sms_own_id_only = "My ID only", // MaxLen: 16
.message_from = "Message from", // MaxLen: 16
.delete_message = "Delete message?", // MaxLen: 16
};
/********************************************************************
*

View file

@ -329,6 +329,22 @@ const stringsTable_t polishLanguage =
.mute = "Mute", // MaxLen: 16 (with ':' + .on or .off)
.delete_channel = "Delete Channel", // MaxLen: 16
.keep_one_channel = "Keep 1 channel", // MaxLen: 16
.messages = "Messages", // MaxLen: 16
.new_message = "New message", // MaxLen: 16
.inbox = "Inbox", // MaxLen: 16
.sent_box = "Sent", // MaxLen: 16
.quick_text = "Quick text", // MaxLen: 16
.send_to = "Send to", // MaxLen: 16
.sms_sending = "Sending", // MaxLen: 16
.sms_sent = "Sent", // MaxLen: 16
.sms_delivered = "Delivered", // MaxLen: 16
.sms_no_ack = "No ACK", // MaxLen: 16
.sms_resend = "Resend?", // MaxLen: 16
.sms_channel_busy = "Channel busy", // MaxLen: 16
.sms_wait_for_ack = "Wait for ACK", // MaxLen: 16
.sms_own_id_only = "My ID only", // MaxLen: 16
.message_from = "Message from", // MaxLen: 16
.delete_message = "Delete message?", // MaxLen: 16
};
/********************************************************************
*

View file

@ -329,6 +329,22 @@ const stringsTable_t portuguesBrazilLanguage =
.mute = "Mute", // MaxLen: 16 (with ':' + .on or .off)
.delete_channel = "Apagar canal", // MaxLen: 16
.keep_one_channel = "Manter 1 canal", // MaxLen: 16
.messages = "Mensagens", // MaxLen: 16
.new_message = "Nova mensagem", // MaxLen: 16
.inbox = "Recebidas", // MaxLen: 16
.sent_box = "Enviadas", // MaxLen: 16
.quick_text = "Texto rápido", // MaxLen: 16
.send_to = "Enviar para", // MaxLen: 16
.sms_sending = "Enviando", // MaxLen: 16
.sms_sent = "Enviada", // MaxLen: 16
.sms_delivered = "Entregue", // MaxLen: 16
.sms_no_ack = "Sem ACK", // MaxLen: 16
.sms_resend = "Reenviar?", // MaxLen: 16
.sms_channel_busy = "Canal ocupado", // MaxLen: 16
.sms_wait_for_ack = "Esperar ACK", // MaxLen: 16
.sms_own_id_only = "Só meu ID", // MaxLen: 16
.message_from = "Mensagem de", // MaxLen: 16
.delete_message = "Apagar msg?", // MaxLen: 16
};
/*********************************************************************
*

View file

@ -329,6 +329,22 @@ const stringsTable_t portuguesLanguage =
.mute = "Mute", // MaxLen: 16 (with ':' + .on or .off)
.delete_channel = "Apagar canal", // MaxLen: 16
.keep_one_channel = "Manter 1 canal", // MaxLen: 16
.messages = "Mensagens", // MaxLen: 16
.new_message = "Nova mensagem", // MaxLen: 16
.inbox = "Recebidas", // MaxLen: 16
.sent_box = "Enviadas", // MaxLen: 16
.quick_text = "Texto rápido", // MaxLen: 16
.send_to = "Enviar para", // MaxLen: 16
.sms_sending = "Enviando", // MaxLen: 16
.sms_sent = "Enviada", // MaxLen: 16
.sms_delivered = "Entregue", // MaxLen: 16
.sms_no_ack = "Sem ACK", // MaxLen: 16
.sms_resend = "Reenviar?", // MaxLen: 16
.sms_channel_busy = "Canal ocupado", // MaxLen: 16
.sms_wait_for_ack = "Esperar ACK", // MaxLen: 16
.sms_own_id_only = "Só meu ID", // MaxLen: 16
.message_from = "Mensagem de", // MaxLen: 16
.delete_message = "Apagar msg?", // MaxLen: 16
};
/********************************************************************
*

View file

@ -329,6 +329,22 @@ const stringsTable_t romanianLanguage =
.mute = "Mute", // MaxLen: 16 (with ':' + .on or .off)
.delete_channel = "Delete Channel", // MaxLen: 16
.keep_one_channel = "Keep 1 channel", // MaxLen: 16
.messages = "Messages", // MaxLen: 16
.new_message = "New message", // MaxLen: 16
.inbox = "Inbox", // MaxLen: 16
.sent_box = "Sent", // MaxLen: 16
.quick_text = "Quick text", // MaxLen: 16
.send_to = "Send to", // MaxLen: 16
.sms_sending = "Sending", // MaxLen: 16
.sms_sent = "Sent", // MaxLen: 16
.sms_delivered = "Delivered", // MaxLen: 16
.sms_no_ack = "No ACK", // MaxLen: 16
.sms_resend = "Resend?", // MaxLen: 16
.sms_channel_busy = "Channel busy", // MaxLen: 16
.sms_wait_for_ack = "Wait for ACK", // MaxLen: 16
.sms_own_id_only = "My ID only", // MaxLen: 16
.message_from = "Message from", // MaxLen: 16
.delete_message = "Delete message?", // MaxLen: 16
};
/********************************************************************
*

View file

@ -329,6 +329,22 @@ const stringsTable_t slovenianLanguage =
.mute = "Mute", // MaxLen: 16 (with ':' + .on or .off)
.delete_channel = "Delete Channel", // MaxLen: 16
.keep_one_channel = "Keep 1 channel", // MaxLen: 16
.messages = "Messages", // MaxLen: 16
.new_message = "New message", // MaxLen: 16
.inbox = "Inbox", // MaxLen: 16
.sent_box = "Sent", // MaxLen: 16
.quick_text = "Quick text", // MaxLen: 16
.send_to = "Send to", // MaxLen: 16
.sms_sending = "Sending", // MaxLen: 16
.sms_sent = "Sent", // MaxLen: 16
.sms_delivered = "Delivered", // MaxLen: 16
.sms_no_ack = "No ACK", // MaxLen: 16
.sms_resend = "Resend?", // MaxLen: 16
.sms_channel_busy = "Channel busy", // MaxLen: 16
.sms_wait_for_ack = "Wait for ACK", // MaxLen: 16
.sms_own_id_only = "My ID only", // MaxLen: 16
.message_from = "Message from", // MaxLen: 16
.delete_message = "Delete message?", // MaxLen: 16
};
/********************************************************************
*

View file

@ -329,6 +329,22 @@ const stringsTable_t spanishLanguage =
.mute = "Mute", // MaxLen: 16 (with ':' + .on or .off)
.delete_channel = "Borrar canal", // MaxLen: 16
.keep_one_channel = "Dejar 1 canal", // MaxLen: 16
.messages = "Mensajes", // MaxLen: 16
.new_message = "Nuevo mensaje", // MaxLen: 16
.inbox = "Recibidos", // MaxLen: 16
.sent_box = "Enviados", // MaxLen: 16
.quick_text = "Texto rápido", // MaxLen: 16
.send_to = "Enviar a", // MaxLen: 16
.sms_sending = "Enviando", // MaxLen: 16
.sms_sent = "Enviado", // MaxLen: 16
.sms_delivered = "Entregado", // MaxLen: 16
.sms_no_ack = "Sin ACK", // MaxLen: 16
.sms_resend = "¿Reenviar?", // MaxLen: 16
.sms_channel_busy = "Canal ocupado", // MaxLen: 16
.sms_wait_for_ack = "Esperar ACK", // MaxLen: 16
.sms_own_id_only = "Solo mi ID", // MaxLen: 16
.message_from = "Mensaje de", // MaxLen: 16
.delete_message = "¿Borrar mensaje?", // MaxLen: 16
};
/********************************************************************
*

View file

@ -329,6 +329,22 @@ const stringsTable_t swedishLanguage =
.mute = "Mute", // MaxLen: 16 (with ':' + .on or .off)
.delete_channel = "Radera kanal", // MaxLen: 16
.keep_one_channel = "Behåll 1 kanal", // MaxLen: 16
.messages = "Messages", // MaxLen: 16
.new_message = "New message", // MaxLen: 16
.inbox = "Inbox", // MaxLen: 16
.sent_box = "Sent", // MaxLen: 16
.quick_text = "Quick text", // MaxLen: 16
.send_to = "Send to", // MaxLen: 16
.sms_sending = "Sending", // MaxLen: 16
.sms_sent = "Sent", // MaxLen: 16
.sms_delivered = "Delivered", // MaxLen: 16
.sms_no_ack = "No ACK", // MaxLen: 16
.sms_resend = "Resend?", // MaxLen: 16
.sms_channel_busy = "Channel busy", // MaxLen: 16
.sms_wait_for_ack = "Wait for ACK", // MaxLen: 16
.sms_own_id_only = "My ID only", // MaxLen: 16
.message_from = "Message from", // MaxLen: 16
.delete_message = "Delete message?", // MaxLen: 16
};
/********************************************************************
*

View file

@ -329,6 +329,22 @@ const stringsTable_t turkishLanguage =
.mute = "Mute", // MaxLen: 16 (with ':' + .on or .off)
.delete_channel = "Delete Channel", // MaxLen: 16
.keep_one_channel = "Keep 1 channel", // MaxLen: 16
.messages = "Messages", // MaxLen: 16
.new_message = "New message", // MaxLen: 16
.inbox = "Inbox", // MaxLen: 16
.sent_box = "Sent", // MaxLen: 16
.quick_text = "Quick text", // MaxLen: 16
.send_to = "Send to", // MaxLen: 16
.sms_sending = "Sending", // MaxLen: 16
.sms_sent = "Sent", // MaxLen: 16
.sms_delivered = "Delivered", // MaxLen: 16
.sms_no_ack = "No ACK", // MaxLen: 16
.sms_resend = "Resend?", // MaxLen: 16
.sms_channel_busy = "Channel busy", // MaxLen: 16
.sms_wait_for_ack = "Wait for ACK", // MaxLen: 16
.sms_own_id_only = "My ID only", // MaxLen: 16
.message_from = "Message from", // MaxLen: 16
.delete_message = "Delete message?", // MaxLen: 16
};
/********************************************************************
*

View file

@ -35,7 +35,7 @@
#else
#define LANGUAGE_TAG_MAGIC_NUMBER { 'G', 'D', '7', '7' }, { 'L', 'A', 'N', 'G' } // Never change this tag.
#endif
#define LANGUAGE_TAG_VERSION { 0x00, 0x00, 0x00, 0x05 } // Bump the version each time the language struct is changed
#define LANGUAGE_TAG_VERSION { 0x00, 0x00, 0x00, 0x06 } // Bump the version each time the language struct is changed
typedef struct
{
@ -337,6 +337,22 @@ typedef struct
const char mute[LANGUAGE_TEXTS_LENGTH];
const char delete_channel[LANGUAGE_TEXTS_LENGTH];
const char keep_one_channel[LANGUAGE_TEXTS_LENGTH];
const char messages[LANGUAGE_TEXTS_LENGTH]; // 282, keep in sync with menuSystem.c smsMenuItems/mainMenuItems
const char new_message[LANGUAGE_TEXTS_LENGTH]; // 283
const char inbox[LANGUAGE_TEXTS_LENGTH]; // 284
const char sent_box[LANGUAGE_TEXTS_LENGTH]; // 285
const char quick_text[LANGUAGE_TEXTS_LENGTH]; // 286
const char send_to[LANGUAGE_TEXTS_LENGTH];
const char sms_sending[LANGUAGE_TEXTS_LENGTH];
const char sms_sent[LANGUAGE_TEXTS_LENGTH];
const char sms_delivered[LANGUAGE_TEXTS_LENGTH];
const char sms_no_ack[LANGUAGE_TEXTS_LENGTH];
const char sms_resend[LANGUAGE_TEXTS_LENGTH];
const char sms_channel_busy[LANGUAGE_TEXTS_LENGTH];
const char sms_wait_for_ack[LANGUAGE_TEXTS_LENGTH];
const char sms_own_id_only[LANGUAGE_TEXTS_LENGTH];
const char message_from[LANGUAGE_TEXTS_LENGTH];
const char delete_message[LANGUAGE_TEXTS_LENGTH];
} stringsTable_t;
#endif // _OPENGD77_UILANGUAGE_H_

View file

@ -320,7 +320,15 @@ enum MENU_SCREENS
#if !defined(PLATFORM_GD77S)
MENU_APRS,
#endif
MENU_SMS,// DMR text messages hub
// *** Add new menus to be accessed using quickkey (ID: 0..31) above this line ***
MENU_SMS_COMPOSE,
MENU_SMS_INBOX,
MENU_SMS_SENT,
MENU_SMS_VIEW,
MENU_SMS_QUICKTEXT,
MENU_SMS_OPTIONS,
UI_SMS_RX_POPUP,
UI_MESSAGE_BOX,
UI_HOTSPOT_MODE,
UI_CPS,
@ -437,6 +445,7 @@ extern menuDataGlobal_t menuDataGlobal;
extern const menuItemsList_t menuDataMainMenu;
extern const menuItemsList_t menuDataContact;
extern const menuItemsList_t menuDataOptions;
extern const menuItemsList_t menuDataSMS;
extern bool lockscreenIsRearming;
menuStatus_t uiVFOMode(uiEvent_t *event, bool isFirstRun);
@ -483,4 +492,14 @@ menuStatus_t menuThemeItemsBrowser(uiEvent_t *ev, bool isFirstRun);
menuStatus_t menuColourPicker(uiEvent_t *ev, bool isFirstRun);
#endif
// DMR text messages (SMS)
menuStatus_t menuSMSCompose(uiEvent_t *ev, bool isFirstRun);
menuStatus_t menuSMSList(uiEvent_t *ev, bool isFirstRun); // serves inbox, sent and view
menuStatus_t menuSMSQuickText(uiEvent_t *ev, bool isFirstRun);
menuStatus_t menuSMSOptions(uiEvent_t *ev, bool isFirstRun);
menuStatus_t uiSMSPopup(uiEvent_t *ev, bool isFirstRun);
void menuSMSComposePrefill(uint32_t dstId, const char *text); // 0 / NULL leave the field untouched
void menuSMSComposeSetQuickTextEditTarget(int slotIndex); // compose edits a quick text instead of sending
void menuSMSListSetViewTarget(bool sentBox, uint8_t recordIndex);
#endif

View file

@ -53,6 +53,8 @@
#include "interfaces/settingsStorage.h"
#include "interfaces/adc.h"
#include "functions/rxPowerSaving.h"
#include "functions/smsCore.h"
#include "functions/smsStorage.h"
#if defined(USING_EXTERNAL_DEBUGGER)
#include "SeggerRTT/RTT/SEGGER_RTT.h"
@ -63,6 +65,68 @@
// Couldn't declare into aprs.h, due to header cross-dependence.
void aprsBeaconingTick(uiEvent_t *ev);
static bool smsResendTimeoutCallback(void)
{
if (uiDataGlobal.MessageBox.keyPressed == KEY_GREEN)
{
smsResendLast();
}
return true;
}
static void smsHandleEvents(void)
{
for (smsEvent_t smsEvent = smsPopEvent(); smsEvent != SMS_EVENT_NONE; smsEvent = smsPopEvent())
{
switch (smsEvent)
{
case SMS_EVENT_TX_SENDING:
uiNotificationShow(NOTIFICATION_TYPE_MESSAGE, NOTIFICATION_ID_MESSAGE, 2000, currentLanguage->sms_sending, true);
break;
case SMS_EVENT_TX_SENT:
uiNotificationShow(NOTIFICATION_TYPE_MESSAGE, NOTIFICATION_ID_MESSAGE, 2000, currentLanguage->sms_sent, true);
break;
case SMS_EVENT_TX_DELIVERED:
uiNotificationShow(NOTIFICATION_TYPE_MESSAGE, NOTIFICATION_ID_MESSAGE, 2000, currentLanguage->sms_delivered, true);
if (melody_play == NULL)
{
soundSetMelody(MELODY_ACK_BEEP);
}
break;
case SMS_EVENT_TX_CHANNEL_BUSY:
uiNotificationShow(NOTIFICATION_TYPE_MESSAGE, NOTIFICATION_ID_MESSAGE, 2000, currentLanguage->sms_channel_busy, true);
if (melody_play == NULL)
{
soundSetMelody(MELODY_NACK_BEEP);
}
break;
case SMS_EVENT_TX_TIMEOUT:
if (smsCanResendLast())
{
snprintf(uiDataGlobal.MessageBox.message, MESSAGEBOX_MESSAGE_LEN_MAX, "%s\n%s",
currentLanguage->sms_no_ack, currentLanguage->sms_resend);
uiDataGlobal.MessageBox.type = MESSAGEBOX_TYPE_INFO;
uiDataGlobal.MessageBox.buttons = MESSAGEBOX_BUTTONS_YESNO;
uiDataGlobal.MessageBox.decoration = MESSAGEBOX_DECORATION_FRAME;
uiDataGlobal.MessageBox.validatorCallback = smsResendTimeoutCallback;
menuSystemPushNewMenu(UI_MESSAGE_BOX);
}
break;
case SMS_EVENT_RX_MESSAGE: // the channel/VFO screens open the popup
default:
break;
}
}
}
volatile bool mainIsRunning = true;
static bool updateMessageOnScreen = false;
int8_t lastVolume = 0;
@ -506,6 +570,9 @@ void applicationMainTask(void)
}
#endif
smsStorageInit();
smsCoreInit();
menuSystemInit(getRtcTime_custom());
#if defined(HAS_GPS)
@ -1031,6 +1098,7 @@ void applicationMainTask(void)
if ((trxGetMode() != RADIO_MODE_NONE) &&
(settingsUsbMode != USB_MODE_HOTSPOT) &&
(HRC6000IsSendingSMS() == false) && // no voice TX while a message is being sent
(currentMenu != UI_POWER_OFF) &&
(currentMenu != UI_SPLASH_SCREEN) &&
(currentMenu != UI_TX_SCREEN)&&
@ -1379,6 +1447,9 @@ void applicationMainTask(void)
voxTick();
gpsTick();
aprsBeaconingTick(&ev);
smsCoreTick();
smsStorageTick();
smsHandleEvents();
settingsSaveIfNeeded(false);
if (settingsIsOptionBitSet(BIT_DISPLAY_TIME_IN_HEADER))

View file

@ -0,0 +1,571 @@
/*
* Copyright (C) 2026-today Marcus Kida, DK1DA
*
*
* Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions
* are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer
* in the documentation and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived
* from this software without specific prior written permission.
*
* 4. Use of this source code or binary releases for commercial purposes is strictly forbidden. This includes, without limitation,
* incorporation in a commercial product or incorporation into a product or project which allows commercial use.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
*/
#include <string.h>
#include "functions/dmrDataProtocol.h"
// CRC masks from ETSI TS 102 361-1 B.3.11/B.3.12 (per data type)
#define CSBK_CRC_MASK 0xA5A5
#define DATA_HEADER_CRC_MASK 0xCCCC
// Data Packet Format values (data header octet 0, low nibble)
#define DPF_RESPONSE 0x01
#define DPF_UNCONFIRMED 0x02
#define DPF_CONFIRMED 0x03
#define SAP_UDP_IP 0x04
#define IP_HEADER_LENGTH 20
#define UDP_HEADER_LENGTH 8
#define TMS_HEADER_LENGTH 10
#define STANDARD_HEADER_LENGTH 4
#define CRC32_TRAILER_LENGTH 4
// TMS message type octet (payload offset 30): 0xA0 plain text,
// 0xE0 text with delivery-report request.
#define TMS_TYPE_TEXT 0xA0
#define TMS_TYPE_TEXT_ACK_REQUEST 0xE0
uint16_t dmrDataCRC16(const uint8_t *data, uint16_t length)
{
uint16_t crc = 0x0000;
for (uint16_t i = 0; i < length; i++)
{
crc ^= ((uint16_t)data[i]) << 8;
for (uint8_t bit = 0; bit < 8; bit++)
{
crc = (crc & 0x8000) ? ((crc << 1) ^ 0x1021) : (crc << 1);
}
}
return crc;
}
// ETSI DMR CRC32 (poly 0x04C11DB7, init 0, MSB first). The input is consumed
// as byte-swapped 16-bit pairs, hence length must be even.
uint32_t dmrDataCRC32(const uint8_t *data, uint16_t length)
{
uint32_t crc = 0;
for (uint16_t i = 0; i < length; i++)
{
crc ^= ((uint32_t)data[i ^ 1]) << 24;
for (uint8_t bit = 0; bit < 8; bit++)
{
crc = (crc & 0x80000000U) ? ((crc << 1) ^ 0x04C11DB7U) : (crc << 1);
}
}
return crc;
}
// Ones-complement sum used by both the IP header and UDP checksums
static uint32_t onesComplementSum(uint32_t sum, const uint8_t *data, uint16_t length)
{
uint16_t i = 0;
while (length > 1)
{
sum += (((uint16_t)data[i]) << 8) | data[i + 1];
i += 2;
length -= 2;
}
if (length == 1)
{
sum += ((uint16_t)data[i]) << 8;
}
return sum;
}
static uint16_t onesComplementFold(uint32_t sum)
{
while (sum >> 16)
{
sum = (sum & 0xFFFF) + (sum >> 16);
}
return (uint16_t)(~sum);
}
uint16_t dmrDataIPHeaderChecksum(const uint8_t *ipHeader)
{
return onesComplementFold(onesComplementSum(0, ipHeader, IP_HEADER_LENGTH));
}
uint16_t dmrDataUDPChecksum(const uint8_t *ipPacket, uint16_t udpLength)
{
uint8_t pseudoHeader[12];
memcpy(&pseudoHeader[0], &ipPacket[12], 4); // source IP
memcpy(&pseudoHeader[4], &ipPacket[16], 4); // destination IP
pseudoHeader[8] = 0x00;
pseudoHeader[9] = 0x11; // UDP protocol
pseudoHeader[10] = (udpLength >> 8) & 0xFF;
pseudoHeader[11] = udpLength & 0xFF;
uint32_t sum = onesComplementSum(0, pseudoHeader, sizeof(pseudoHeader));
uint16_t checksum = onesComplementFold(onesComplementSum(sum, &ipPacket[IP_HEADER_LENGTH], udpLength));
// All-zero means "no checksum" in UDP, transmit as 0xFFFF instead
return ((checksum == 0x0000) ? 0xFFFF : checksum);
}
static void put24BE(uint8_t *dest, uint32_t value)
{
dest[0] = (value >> 16) & 0xFF;
dest[1] = (value >> 8) & 0xFF;
dest[2] = value & 0xFF;
}
static uint32_t get24BE(const uint8_t *src)
{
return (((uint32_t)src[0]) << 16) | (((uint32_t)src[1]) << 8) | src[2];
}
// ETSI TS 102 361-1 B.3.11: the CCITT CRC is ones-complemented, then XORed
// with the data-type mask (validated against on-air captures).
static void applyFrameCRC(uint8_t frame[DMR_DATA_BURST_LENGTH], uint16_t mask)
{
uint16_t crc = (uint16_t)(~dmrDataCRC16(frame, DMR_DATA_BURST_LENGTH - 2)) ^ mask;
frame[10] = (crc >> 8) & 0xFF;
frame[11] = crc & 0xFF;
}
void dmrDataBuildPreambleCSBK(uint8_t frame[DMR_DATA_BURST_LENGTH], uint32_t dstId, uint32_t srcId, uint8_t blocksToFollow)
{
frame[0] = 0xBD; // Last Block flag + Preamble CSBK opcode (0x3D)
frame[1] = 0x00; // Feature set ID: standardized
frame[2] = 0x80; // data content follows, target is an individual ID
frame[3] = blocksToFollow;
put24BE(&frame[4], dstId);
put24BE(&frame[7], srcId);
applyFrameCRC(frame, CSBK_CRC_MASK);
}
void dmrDataBuildUnconfirmedHeader(uint8_t frame[DMR_DATA_BURST_LENGTH], uint32_t dstId, uint32_t srcId,
uint8_t blockCount, uint8_t padOctets, bool requestResponse)
{
frame[0] = DPF_UNCONFIRMED | (requestResponse ? 0x40 : 0x00) | (padOctets & 0x10);
frame[1] = (SAP_UDP_IP << 4) | (padOctets & 0x0F);
put24BE(&frame[2], dstId);
put24BE(&frame[5], srcId);
frame[8] = 0x80 | (blockCount & 0x7F); // full message, blocks to follow
frame[9] = 0x00; // fragment sequence number
applyFrameCRC(frame, DATA_HEADER_CRC_MASK);
}
void dmrDataBuildResponseHeader(uint8_t frame[DMR_DATA_BURST_LENGTH], uint32_t dstId, uint32_t srcId, bool repeaterProfile)
{
frame[0] = DPF_RESPONSE;
frame[1] = repeaterProfile ? (SAP_UDP_IP << 4) : 0x00;
put24BE(&frame[2], dstId);
put24BE(&frame[5], srcId);
frame[8] = 0x00; // no blocks to follow
frame[9] = repeaterProfile ? 0x08 : 0x00; // response type: ACK
applyFrameCRC(frame, DATA_HEADER_CRC_MASK);
}
bool dmrDataParseHeader(const uint8_t frame[DMR_DATA_BURST_LENGTH], dmrDataHeader_t *header)
{
uint16_t receivedCRC = (((uint16_t)frame[10]) << 8) | frame[11];
if (((uint16_t)(~dmrDataCRC16(frame, DMR_DATA_BURST_LENGTH - 2)) ^ DATA_HEADER_CRC_MASK) != receivedCRC)
{
return false;
}
header->isGroup = ((frame[0] & 0x80) != 0);
header->responseRequested = ((frame[0] & 0x40) != 0);
header->dpf = frame[0] & 0x0F;
header->sap = (frame[1] >> 4) & 0x0F;
header->padOctets = (frame[0] & 0x10) | (frame[1] & 0x0F);
header->dstId = get24BE(&frame[2]);
header->srcId = get24BE(&frame[5]);
header->blockCount = frame[8] & 0x7F;
header->isResponsePdu = ((header->dpf == DPF_RESPONSE) && (frame[8] == 0x00));
switch (header->dpf)
{
case DPF_RESPONSE:
case DPF_UNCONFIRMED:
case DPF_CONFIRMED:
return true;
default:
return false;
}
}
// Map one UTF-16 code unit to displayable ASCII. Both endiannesses appear in
// the wild (spec examples and BrandMeister use little-endian), so decode is
// per-character adaptive.
static char utf16ToChar(uint8_t first, uint8_t second)
{
uint8_t ascii;
if (second == 0x00)
{
ascii = first; // little-endian
}
else if (first == 0x00)
{
ascii = second; // big-endian
}
else
{
return '?'; // non latin-1 code point
}
if ((ascii >= 0x20) && (ascii < 0x7F))
{
return (char)ascii;
}
if ((ascii == '\r') || (ascii == '\n') || (ascii == '\t'))
{
return ' '; // messages are displayed as a single flowed text
}
return '?';
}
static void decodeUtf16Text(const uint8_t *data, uint16_t byteCount, char *textOut, uint16_t textOutSize)
{
uint16_t outPos = 0;
for (uint16_t i = 0; (i + 1) < byteCount; i += 2)
{
if (outPos >= (textOutSize - 1))
{
break;
}
if ((data[i] == 0x00) && (data[i + 1] == 0x00))
{
break; // embedded terminator
}
textOut[outPos++] = utf16ToChar(data[i], data[i + 1]);
}
// strip trailing '?' produced by stray padding
while ((outPos > 0) && (textOut[outPos - 1] == '?'))
{
outPos--;
}
textOut[outPos] = 0;
}
smsEncodeResult_t smsPayloadBuildMotorola(uint8_t *payload, uint16_t *payloadLength, uint8_t *padOctets,
uint32_t dstId, uint32_t srcId, const char *text, uint16_t ipSequenceNumber)
{
uint16_t textLength = (uint16_t)strlen(text);
if (textLength == 0)
{
return SMS_ENCODE_EMPTY;
}
if (textLength > SMS_MAX_TEXT_LENGTH)
{
return SMS_ENCODE_TOO_LONG;
}
uint16_t textBytes = textLength * 2;
uint16_t ipLength = SMS_TEXT_OFFSET_MOTOROLA + textBytes; // headers + text, excludes padding and CRC32
uint16_t udpLength = UDP_HEADER_LENGTH + TMS_HEADER_LENGTH + textBytes;
uint16_t totalLength = ipLength + CRC32_TRAILER_LENGTH;
// round up to a whole number of rate-1/2 blocks
totalLength = ((totalLength + (DMR_DATA_BURST_LENGTH - 1)) / DMR_DATA_BURST_LENGTH) * DMR_DATA_BURST_LENGTH;
uint8_t pad = (uint8_t)(totalLength - ipLength - CRC32_TRAILER_LENGTH);
memset(payload, 0, totalLength);
// IPv4 header
payload[0] = 0x45; // version 4, IHL 5
payload[1] = 0x00;
payload[2] = (ipLength >> 8) & 0xFF;
payload[3] = ipLength & 0xFF;
payload[4] = (ipSequenceNumber >> 8) & 0xFF;
payload[5] = ipSequenceNumber & 0xFF;
payload[6] = 0x00; // flags / fragment offset
payload[7] = 0x00;
payload[8] = 0x01; // TTL
payload[9] = 0x11; // UDP
payload[10] = 0x00; // checksum placeholder
payload[11] = 0x00;
payload[12] = 0x0C; // individual DMR ID address space
put24BE(&payload[13], srcId);
payload[16] = 0x0C;
put24BE(&payload[17], dstId);
uint16_t ipChecksum = dmrDataIPHeaderChecksum(payload);
payload[10] = (ipChecksum >> 8) & 0xFF;
payload[11] = ipChecksum & 0xFF;
// UDP header
payload[20] = (SMS_UDP_PORT_MOTOROLA >> 8) & 0xFF;
payload[21] = SMS_UDP_PORT_MOTOROLA & 0xFF;
payload[22] = (SMS_UDP_PORT_MOTOROLA >> 8) & 0xFF;
payload[23] = SMS_UDP_PORT_MOTOROLA & 0xFF;
payload[24] = (udpLength >> 8) & 0xFF;
payload[25] = udpLength & 0xFF;
payload[26] = 0x00; // checksum placeholder
payload[27] = 0x00;
// TMS header
payload[28] = 0x00;
payload[29] = (textBytes + 8) & 0xFF;
payload[30] = TMS_TYPE_TEXT_ACK_REQUEST;
payload[31] = 0x00;
payload[32] = (ipSequenceNumber & 0x7F) | 0x80;
payload[33] = 0x04;
payload[34] = 0x0D;
payload[35] = 0x00;
payload[36] = 0x0A;
payload[37] = 0x00;
// UTF-16LE text, uppercased ASCII for best cross-vendor display compatibility
for (uint16_t i = 0; i < textLength; i++)
{
char c = text[i];
if ((c >= 'a') && (c <= 'z'))
{
c -= ('a' - 'A');
}
else if (((c < 0x20) || (c >= 0x7F)) && (c != '\r') && (c != '\n'))
{
c = ' ';
}
payload[SMS_TEXT_OFFSET_MOTOROLA + (i * 2)] = (uint8_t)c;
payload[SMS_TEXT_OFFSET_MOTOROLA + (i * 2) + 1] = 0x00;
}
uint16_t udpChecksum = dmrDataUDPChecksum(payload, udpLength);
payload[26] = (udpChecksum >> 8) & 0xFF;
payload[27] = udpChecksum & 0xFF;
// trailing CRC32 (little-endian) over everything before it
uint32_t crc = dmrDataCRC32(payload, totalLength - CRC32_TRAILER_LENGTH);
payload[totalLength - 4] = crc & 0xFF;
payload[totalLength - 3] = (crc >> 8) & 0xFF;
payload[totalLength - 2] = (crc >> 16) & 0xFF;
payload[totalLength - 1] = (crc >> 24) & 0xFF;
*payloadLength = totalLength;
*padOctets = pad;
return SMS_ENCODE_OK;
}
// Common IP/UDP shell validation for both text formats. Returns the UDP
// length, or 0 when the shell is not a plausible SMS packet.
static uint16_t validateIPShell(const uint8_t *payload, uint16_t payloadLength, uint16_t udpPort)
{
if (payloadLength < (IP_HEADER_LENGTH + UDP_HEADER_LENGTH))
{
return 0;
}
if (((payload[0] & 0xF0) != 0x40) || (payload[9] != 0x11)) // IPv4, UDP
{
return 0;
}
if (dmrDataIPHeaderChecksum(payload) != 0) // sum including the checksum field yields 0 when valid
{
return 0;
}
uint16_t srcPort = (((uint16_t)payload[20]) << 8) | payload[21];
uint16_t dstPort = (((uint16_t)payload[22]) << 8) | payload[23];
if ((srcPort != udpPort) && (dstPort != udpPort))
{
return 0;
}
uint16_t udpLength = (((uint16_t)payload[24]) << 8) | payload[25];
if ((udpLength < UDP_HEADER_LENGTH) || ((IP_HEADER_LENGTH + udpLength) > payloadLength))
{
return 0;
}
return udpLength;
}
bool smsPayloadParseMotorola(const uint8_t *payload, uint16_t payloadLength, char *textOut, uint16_t textOutSize)
{
uint16_t udpLength = validateIPShell(payload, payloadLength, SMS_UDP_PORT_MOTOROLA);
if (udpLength < (UDP_HEADER_LENGTH + TMS_HEADER_LENGTH))
{
return false;
}
// TMS header shape: start marker, message type, "text" magic
if ((payload[28] != 0x00) ||
((payload[30] != TMS_TYPE_TEXT) && (payload[30] != TMS_TYPE_TEXT_ACK_REQUEST)) ||
(payload[34] != 0x0D) || (payload[36] != 0x0A))
{
return false;
}
uint16_t textBytes = udpLength - UDP_HEADER_LENGTH - TMS_HEADER_LENGTH;
if ((SMS_TEXT_OFFSET_MOTOROLA + textBytes) > payloadLength)
{
textBytes = payloadLength - SMS_TEXT_OFFSET_MOTOROLA;
}
decodeUtf16Text(&payload[SMS_TEXT_OFFSET_MOTOROLA], textBytes, textOut, textOutSize);
return (textOut[0] != 0);
}
bool smsPayloadParseStandard(const uint8_t *payload, uint16_t payloadLength, char *textOut, uint16_t textOutSize)
{
uint16_t udpLength = validateIPShell(payload, payloadLength, SMS_UDP_PORT_STANDARD);
if (udpLength < (UDP_HEADER_LENGTH + STANDARD_HEADER_LENGTH))
{
return false;
}
if ((payload[28] != 0x00) || (payload[29] != 0x0D) || (payload[31] != 0x0A))
{
return false;
}
uint16_t textBytes = udpLength - UDP_HEADER_LENGTH - STANDARD_HEADER_LENGTH;
if ((SMS_TEXT_OFFSET_STANDARD + textBytes) > payloadLength)
{
textBytes = payloadLength - SMS_TEXT_OFFSET_STANDARD;
}
decodeUtf16Text(&payload[SMS_TEXT_OFFSET_STANDARD], textBytes, textOut, textOutSize);
return (textOut[0] != 0);
}
static bool isDisplayableAscii(uint8_t c)
{
return (((c >= 0x20) && (c < 0x7F)) || (c == '\r') || (c == '\n'));
}
bool smsPayloadScanText(const uint8_t *payload, uint16_t payloadLength, char *textOut, uint16_t textOutSize)
{
uint16_t bestStart = 0;
uint16_t bestLength = 0; // in characters
bool bestBigEndian = false;
// four sweeps: both 16-bit alignments x both endiannesses
for (uint8_t sweep = 0; sweep < 4; sweep++)
{
uint16_t start = sweep & 1;
bool bigEndian = ((sweep & 2) != 0);
uint16_t runStart = start;
uint16_t runLength = 0;
for (uint16_t i = start; (i + 1) < payloadLength; i += 2)
{
uint8_t asciiByte = bigEndian ? payload[i + 1] : payload[i];
uint8_t zeroByte = bigEndian ? payload[i] : payload[i + 1];
if ((zeroByte == 0x00) && isDisplayableAscii(asciiByte))
{
if (runLength == 0)
{
runStart = i;
}
runLength++;
if (runLength > bestLength)
{
bestLength = runLength;
bestStart = runStart;
bestBigEndian = bigEndian;
}
}
else
{
runLength = 0;
}
}
}
if (bestLength < 4)
{
return false;
}
uint16_t outPos = 0;
for (uint16_t i = 0; (i < bestLength) && (outPos < (textOutSize - 1)); i++)
{
uint16_t offset = bestStart + (i * 2);
char c = (char)(bestBigEndian ? payload[offset + 1] : payload[offset]);
if ((c == '\r') || (c == '\n'))
{
c = ' '; // messages are displayed as a single flowed text
}
textOut[outPos++] = c;
}
textOut[outPos] = 0;
// collapse the leading whitespace a run picked up from line breaks
uint16_t start = 0;
while ((textOut[start] == ' ') && (start < outPos))
{
start++;
}
if (start > 0)
{
memmove(textOut, &textOut[start], (outPos - start) + 1);
}
return true;
}

View file

@ -0,0 +1,719 @@
/*
* Copyright (C) 2026-today Marcus Kida, DK1DA
*
*
* Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions
* are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer
* in the documentation and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived
* from this software without specific prior written permission.
*
* 4. Use of this source code or binary releases for commercial purposes is strictly forbidden. This includes, without limitation,
* incorporation in a commercial product or incorporation into a product or project which allows commercial use.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
*/
#include <string.h>
#include "functions/smsCore.h"
#include "functions/smsStorage.h"
#include "functions/ticks.h"
#include "functions/trx.h"
#include "functions/settings.h"
#include "hardware/HR-C6000.h"
#define SMS_WAIT_CHANNEL_TIMEOUT_MS 4000
#define SMS_WAIT_ACK_TIMEOUT_MS 6000
#define SMS_ACK_RESPONSE_DELAY_MS 1500
#define SMS_RX_ASSEMBLY_STALL_MS 2000
#define SMS_RX_DEDUPE_WINDOW_MS 30000
#define SMS_EVENT_QUEUE_SIZE 8
//
// Outgoing transmission
//
static struct
{
volatile smsTxState_t state;
volatile bool jobPending;
bool jobIsAckResponse;
uint32_t peerId;
char text[SMS_MAX_TEXT_LENGTH + 1]; // kept for the sent box and resend
bool haveLastMessage;
ticksTimer_t channelTimer;
ticksTimer_t ackTimer;
uint16_t ipSequence;
} tx;
static smsAirJob_t txAirJob;
// set from ISR context, consumed in smsCoreTick()
static volatile bool txAirDoneFlag = false;
static volatile bool txRejectedFlag = false;
static volatile bool txDeliveredFlag = false;
//
// Incoming assembly (written from ISR context only).
//
// The raw bursts are kept as-received: senders differ in whether the blocks
// carry a confirmed-data prefix, and it is not guaranteed how many of a
// rate-3/4 block's 18 bytes the chip exposes, so the extraction layout is
// determined at decode time by trying the possible models against the IP
// header checksum.
//
#define SMS_RX_MAX_BLOCKS 32
#define SMS_RX_BLOCK_STRIDE DMR_DATA_RATE34_BLOCK_LENGTH
static volatile struct
{
bool active;
uint8_t expectedBlocks;
uint8_t receivedBlocks;
uint8_t framesSeen; // progress marker for the stall watchdog
uint32_t srcId;
bool responseRequested;
bool confirmedData; // header DPF was "confirmed"
bool rate34; // payload arrived as rate-3/4 blocks
} rxAssembly;
// Shared between assembly (ISR) and decoding (main task): while
// rxDecode.pending is set the buffers belong to the main task and the ISR
// won't open a new assembly into them.
static uint8_t rxRawBlocks[SMS_RX_MAX_BLOCKS * SMS_RX_BLOCK_STRIDE];
static __attribute__((section(".ccmram"))) uint8_t rxAssembledPayload[SMS_RX_MAX_BLOCKS * (SMS_RX_BLOCK_STRIDE - DMR_DATA_CONFIRMED_PREFIX_LENGTH)];
// completed assembly handed over for decoding in the main task
static volatile struct
{
bool pending;
uint8_t blockCount;
bool confirmedData;
bool rate34;
uint32_t srcId;
bool responseRequested;
} rxDecode;
//
// Delivery report we owe to a sender
//
static struct
{
bool pending;
uint32_t dstId;
ticksTimer_t delayTimer;
} ackResponse;
// UI event queue and unseen-message flag (main task context only)
static uint8_t eventQueue[SMS_EVENT_QUEUE_SIZE];
static uint8_t eventQueueHead = 0;
static uint8_t eventQueueCount = 0;
static bool unseenRxMessage = false;
static uint32_t lastRxStoreTime = 0;
static void pushEvent(smsEvent_t event)
{
if (eventQueueCount < SMS_EVENT_QUEUE_SIZE)
{
eventQueue[(eventQueueHead + eventQueueCount) % SMS_EVENT_QUEUE_SIZE] = (uint8_t)event;
eventQueueCount++;
}
}
smsEvent_t smsPopEvent(void)
{
if (eventQueueCount == 0)
{
return SMS_EVENT_NONE;
}
smsEvent_t event = (smsEvent_t)eventQueue[eventQueueHead];
eventQueueHead = (eventQueueHead + 1) % SMS_EVENT_QUEUE_SIZE;
eventQueueCount--;
return event;
}
void smsCoreInit(void)
{
memset(&tx, 0, sizeof(tx));
memset((void *)&rxAssembly, 0, sizeof(rxAssembly));
memset((void *)&rxDecode, 0, sizeof(rxDecode));
memset(&ackResponse, 0, sizeof(ackResponse));
eventQueueHead = 0;
eventQueueCount = 0;
unseenRxMessage = false;
txAirDoneFlag = false;
txRejectedFlag = false;
txDeliveredFlag = false;
}
bool smsCoreIsIdle(void)
{
return ((tx.state == SMS_TX_STATE_IDLE) && (tx.jobPending == false) &&
(rxAssembly.active == false) && (rxDecode.pending == false) &&
(ackResponse.pending == false));
}
smsTxState_t smsGetTxState(void)
{
return tx.state;
}
//
// Air job construction. The payload blocks are built in place: the rows of
// txAirJob.frames are contiguous, so the encoder writes the whole payload
// directly into the rows following the preambles and the header.
//
static void buildAirJobFrames(uint32_t dstId, uint32_t srcId, uint8_t preambleCount,
const uint8_t *headerFrame, uint8_t blockCount)
{
uint8_t frameIndex = 0;
for (uint8_t i = 0; i < preambleCount; i++)
{
// remaining frames after this preamble: the other preambles, the
// header, and the payload blocks
uint8_t blocksToFollow = (preambleCount - 1 - i) + 1 + blockCount;
dmrDataBuildPreambleCSBK(txAirJob.frames[frameIndex], dstId, srcId, blocksToFollow);
frameIndex++;
}
memcpy(txAirJob.frames[frameIndex], headerFrame, DMR_DATA_BURST_LENGTH);
frameIndex++;
// the payload block contents are already in place (message jobs), or absent (ACK jobs)
txAirJob.preambleCount = preambleCount;
txAirJob.frameCount = frameIndex + blockCount;
}
static bool queueMessageTransmission(uint32_t dstId, const char *text)
{
uint16_t payloadLength = 0;
uint8_t padOctets = 0;
uint8_t *payloadArea = txAirJob.frames[SMS_TX_PREAMBLE_COUNT + 1]; // rows after preambles + header
tx.ipSequence++;
if (smsPayloadBuildMotorola(payloadArea, &payloadLength, &padOctets,
dstId, trxDMRID, text, tx.ipSequence) != SMS_ENCODE_OK)
{
return false;
}
uint8_t blockCount = payloadLength / DMR_DATA_BURST_LENGTH;
uint8_t headerFrame[DMR_DATA_BURST_LENGTH];
dmrDataBuildUnconfirmedHeader(headerFrame, dstId, trxDMRID, blockCount, padOctets, smsOptionWaitForAck());
buildAirJobFrames(dstId, trxDMRID, SMS_TX_PREAMBLE_COUNT, headerFrame, blockCount);
tx.peerId = dstId;
strncpy(tx.text, text, SMS_MAX_TEXT_LENGTH);
tx.text[SMS_MAX_TEXT_LENGTH] = 0;
tx.haveLastMessage = true;
tx.jobIsAckResponse = false;
tx.jobPending = true;
tx.state = SMS_TX_STATE_WAIT_CHANNEL;
ticksTimerStart(&tx.channelTimer, SMS_WAIT_CHANNEL_TIMEOUT_MS);
return true;
}
static void queueAckResponseTransmission(uint32_t dstId)
{
uint8_t headerFrame[DMR_DATA_BURST_LENGTH];
// repeaters/hotspots expect the SAP + response-type variant of the
// delivery report, OEM radios on simplex the plain one
bool repeaterProfile = (currentChannelData->rxFreq != currentChannelData->txFreq);
dmrDataBuildResponseHeader(headerFrame, dstId, trxDMRID, repeaterProfile);
buildAirJobFrames(dstId, trxDMRID, SMS_TX_ACK_PREAMBLE_COUNT, headerFrame, 0);
tx.jobIsAckResponse = true;
tx.jobPending = true;
tx.state = SMS_TX_STATE_WAIT_CHANNEL;
ticksTimerStart(&tx.channelTimer, SMS_WAIT_CHANNEL_TIMEOUT_MS);
}
static bool smsTransmissionIsAllowed(void)
{
return ((trxGetMode() == RADIO_MODE_DIGITAL) &&
(settingsUsbMode != USB_MODE_HOTSPOT) &&
(settingsIsOptionBitSet(BIT_TX_INHIBIT) == false) &&
(trxTransmissionEnabled == false) &&
(trxIsTransmitting == false));
}
static bool tryStartAirTransmission(void)
{
return HRC6000StartSmsTransmission();
}
bool smsSendMessage(uint32_t dstId, const char *text)
{
if ((tx.state != SMS_TX_STATE_IDLE) || tx.jobPending ||
(dstId == 0) || (dstId > 0xFFFFFF) || (smsTransmissionIsAllowed() == false))
{
return false;
}
return queueMessageTransmission(dstId, text);
}
bool smsCanResendLast(void)
{
return (tx.haveLastMessage && (tx.state == SMS_TX_STATE_IDLE) && (tx.jobPending == false));
}
bool smsResendLast(void)
{
if (smsCanResendLast() == false)
{
return false;
}
return smsSendMessage(tx.peerId, tx.text);
}
//
// RX notification for the popup screen
//
bool smsHasUnseenRxMessage(void)
{
return unseenRxMessage;
}
void smsAcknowledgeRxMessage(void)
{
unseenRxMessage = false;
}
//
// Options (persisted with the message store)
//
bool smsOptionWaitForAck(void)
{
return ((smsStorageGetOptions() & SMS_OPTION_WAIT_FOR_ACK) != 0);
}
void smsOptionSetWaitForAck(bool waitForAck)
{
uint8_t options = smsStorageGetOptions();
smsStorageSetOptions(waitForAck ? (options | SMS_OPTION_WAIT_FOR_ACK) : (options & ~SMS_OPTION_WAIT_FOR_ACK));
}
bool smsOptionOwnIdOnly(void)
{
return ((smsStorageGetOptions() & SMS_OPTION_OWN_ID_ONLY) != 0);
}
void smsOptionSetOwnIdOnly(bool ownIdOnly)
{
uint8_t options = smsStorageGetOptions();
smsStorageSetOptions(ownIdOnly ? (options | SMS_OPTION_OWN_ID_ONLY) : (options & ~SMS_OPTION_OWN_ID_ONLY));
}
//
// ISR-side interface
//
const smsAirJob_t *smsGetPendingAirJob(void)
{
return (tx.jobPending ? &txAirJob : NULL);
}
void smsAirJobStarted(void)
{
tx.jobPending = false;
}
void smsTxNotifyAirDone(void)
{
txAirDoneFlag = true;
}
void smsTxNotifyRejected(void)
{
txRejectedFlag = true;
}
void smsTxNotifyHwAck(void)
{
if (tx.state == SMS_TX_STATE_WAIT_ACK)
{
txDeliveredFlag = true;
}
}
static void rxAssemblyReset(void)
{
rxAssembly.active = false;
rxAssembly.expectedBlocks = 0;
rxAssembly.receivedBlocks = 0;
}
static void rxAssemblyComplete(void)
{
rxDecode.blockCount = ((rxAssembly.receivedBlocks < SMS_RX_MAX_BLOCKS) ? rxAssembly.receivedBlocks : SMS_RX_MAX_BLOCKS);
rxDecode.confirmedData = rxAssembly.confirmedData;
rxDecode.rate34 = rxAssembly.rate34;
rxDecode.srcId = rxAssembly.srcId;
rxDecode.responseRequested = rxAssembly.responseRequested;
rxDecode.pending = true; // buffers now belong to the main task
rxAssemblyReset();
}
bool smsRxProcessDataFrame(uint8_t dataType, const uint8_t *frame)
{
rxAssembly.framesSeen++;
if (dataType == DMR_DATA_TYPE_DATA_HEADER)
{
dmrDataHeader_t header;
if (dmrDataParseHeader(frame, &header) == false)
{
return false;
}
// delivery report for our outstanding message? (it can arrive while
// the state machine is still finishing up the transmission)
if (header.isResponsePdu)
{
if (((tx.state == SMS_TX_STATE_WAIT_ACK) || (tx.state == SMS_TX_STATE_SENDING)) &&
(header.srcId == tx.peerId))
{
txDeliveredFlag = true;
}
return true;
}
// Longer messages than the raw-block store still assemble; the text
// beyond the stored blocks is truncated at decode time
if ((header.sap != 0x04) || header.isGroup ||
(header.blockCount == 0) || (header.blockCount > SMS_MAX_DATA_BLOCKS))
{
return false;
}
if (smsOptionOwnIdOnly() && (header.dstId != trxDMRID))
{
return false;
}
if (rxDecode.pending) // buffers still owned by the decoder, drop this message
{
return false;
}
rxAssembly.expectedBlocks = header.blockCount;
rxAssembly.receivedBlocks = 0;
rxAssembly.srcId = header.srcId;
rxAssembly.responseRequested = header.responseRequested;
rxAssembly.confirmedData = (header.dpf == 0x03);
rxAssembly.rate34 = false;
rxAssembly.active = true;
return true;
}
if (rxAssembly.active == false)
{
return false;
}
if ((dataType != DMR_DATA_TYPE_RATE_12_DATA) && (dataType != DMR_DATA_TYPE_RATE_34_DATA))
{
return false;
}
if (rxAssembly.receivedBlocks < SMS_RX_MAX_BLOCKS)
{
// keep the burst raw; the extraction layout is decided at decode time
memcpy(&rxRawBlocks[rxAssembly.receivedBlocks * SMS_RX_BLOCK_STRIDE], frame,
((dataType == DMR_DATA_TYPE_RATE_34_DATA) ? DMR_DATA_RATE34_BLOCK_LENGTH : DMR_DATA_BURST_LENGTH));
if (rxAssembly.receivedBlocks == 0)
{
rxAssembly.rate34 = (dataType == DMR_DATA_TYPE_RATE_34_DATA);
}
}
rxAssembly.receivedBlocks++;
if (rxAssembly.receivedBlocks >= rxAssembly.expectedBlocks)
{
rxAssemblyComplete();
}
return true;
}
//
// Main task processing
//
// Compact the raw bursts into a contiguous payload, taking `take` bytes
// starting at `skip` from each block. Returns the assembled length.
static uint16_t rxExtractPayload(uint8_t blockCount, uint8_t skip, uint8_t take)
{
for (uint8_t i = 0; i < blockCount; i++)
{
memcpy(&rxAssembledPayload[i * take], &rxRawBlocks[(i * SMS_RX_BLOCK_STRIDE) + skip], take);
}
return (uint16_t)blockCount * take;
}
static void processCompletedRxMessage(void)
{
char text[SMS_MAX_TEXT_LENGTH + 1];
uint32_t srcId = rxDecode.srcId;
bool responseRequested = rxDecode.responseRequested;
// Candidate block layouts, likeliest first (based on the header's
// confirmed/unconfirmed DPF). For rate-3/4, the 12-byte layouts cover
// the case where the chip exposes only part of the 18-byte block.
const uint8_t prefix = DMR_DATA_CONFIRMED_PREFIX_LENGTH;
uint8_t blockLengths[4];
uint8_t skips[4];
uint8_t modelCount;
if (rxDecode.rate34)
{
blockLengths[0] = DMR_DATA_RATE34_BLOCK_LENGTH;
blockLengths[1] = DMR_DATA_BURST_LENGTH;
blockLengths[2] = DMR_DATA_RATE34_BLOCK_LENGTH;
blockLengths[3] = DMR_DATA_BURST_LENGTH;
skips[0] = skips[1] = (rxDecode.confirmedData ? prefix : 0);
skips[2] = skips[3] = (rxDecode.confirmedData ? 0 : prefix);
modelCount = 4;
}
else
{
blockLengths[0] = blockLengths[1] = DMR_DATA_BURST_LENGTH;
skips[0] = (rxDecode.confirmedData ? prefix : 0);
skips[1] = (rxDecode.confirmedData ? 0 : prefix);
modelCount = 2;
}
bool decoded = false;
uint16_t length = 0;
for (uint8_t m = 0; (m < modelCount) && (decoded == false); m++)
{
length = rxExtractPayload(rxDecode.blockCount, skips[m], blockLengths[m] - skips[m]);
decoded = (smsPayloadParseMotorola(rxAssembledPayload, length, text, sizeof(text)) ||
smsPayloadParseStandard(rxAssembledPayload, length, text, sizeof(text)));
}
if (decoded == false)
{
// No layout produced a structurally valid packet: salvage readable
// text from every layout and keep the longest result
char candidate[SMS_MAX_TEXT_LENGTH + 1];
text[0] = 0;
for (uint8_t m = 0; m < modelCount; m++)
{
length = rxExtractPayload(rxDecode.blockCount, skips[m], blockLengths[m] - skips[m]);
if (smsPayloadScanText(rxAssembledPayload, length, candidate, sizeof(candidate)) &&
(strlen(candidate) > strlen(text)))
{
memcpy(text, candidate, sizeof(text));
}
}
decoded = (text[0] != 0);
}
rxDecode.pending = false; // buffers released back to the ISR assembler
if (decoded)
{
// OEM radios repeat a message when they miss our delivery report;
// don't store consecutive identical copies arriving close together
const smsRecord_t *newest = smsInboxGet(0);
bool isDuplicate = ((newest != NULL) && (newest->peerId == srcId) &&
(strcmp(newest->text, text) == 0) &&
((ticksGetMillis() - lastRxStoreTime) < SMS_RX_DEDUPE_WINDOW_MS));
if (isDuplicate == false)
{
smsInboxAdd(srcId, text);
lastRxStoreTime = ticksGetMillis();
unseenRxMessage = true;
pushEvent(SMS_EVENT_RX_MESSAGE);
}
}
// acknowledge receipt at the data-link level even when the payload
// didn't decode to text - all blocks did arrive
if (responseRequested && (srcId != 0) && (srcId != trxDMRID))
{
ackResponse.pending = true;
ackResponse.dstId = srcId;
ticksTimerStart(&ackResponse.delayTimer, SMS_ACK_RESPONSE_DELAY_MS);
}
}
static void watchRxAssemblyStall(void)
{
static uint8_t lastFramesSeen = 0;
static ticksTimer_t stallTimer = { 0, 0 };
static bool watching = false;
if (rxAssembly.active)
{
if ((watching == false) || (rxAssembly.framesSeen != lastFramesSeen))
{
lastFramesSeen = rxAssembly.framesSeen;
ticksTimerStart(&stallTimer, SMS_RX_ASSEMBLY_STALL_MS);
watching = true;
}
else if (ticksTimerHasExpired(&stallTimer))
{
rxAssemblyReset(); // transmission broke off mid-message
watching = false;
}
}
else
{
watching = false;
}
}
static void finishTransmission(smsEvent_t event)
{
bool wasAckResponse = tx.jobIsAckResponse;
tx.state = SMS_TX_STATE_IDLE;
tx.jobPending = false;
tx.jobIsAckResponse = false;
if ((wasAckResponse == false) && (event != SMS_EVENT_NONE))
{
pushEvent(event);
}
}
void smsCoreTick(void)
{
if (rxDecode.pending)
{
processCompletedRxMessage();
}
watchRxAssemblyStall();
// pending delivery report: transmit once the delay elapsed and we're free
if (ackResponse.pending && ticksTimerHasExpired(&ackResponse.delayTimer) &&
(tx.state == SMS_TX_STATE_IDLE) && (tx.jobPending == false))
{
if (smsTransmissionIsAllowed())
{
queueAckResponseTransmission(ackResponse.dstId);
}
ackResponse.pending = false; // single attempt, dropped when not possible
}
switch (tx.state)
{
case SMS_TX_STATE_WAIT_CHANNEL:
if (tryStartAirTransmission())
{
tx.state = SMS_TX_STATE_SENDING;
if (tx.jobIsAckResponse == false)
{
pushEvent(SMS_EVENT_TX_SENDING);
}
}
else if (ticksTimerHasExpired(&tx.channelTimer))
{
finishTransmission(SMS_EVENT_TX_CHANNEL_BUSY);
}
break;
case SMS_TX_STATE_SENDING:
if (HRC6000GetIsWakingState() == WAKING_MODE_FAILED)
{
// The repeater never woke up; tear the transmission down
HRC6000CancelSmsTransmission();
trxTransmissionEnabled = false;
HRC6000ClearIsWakingState();
HRC6000TerminateDigital();
trxDisableTransmission();
finishTransmission(SMS_EVENT_TX_CHANNEL_BUSY);
}
else if (txRejectedFlag)
{
txRejectedFlag = false;
trxDisableTransmission();
finishTransmission(SMS_EVENT_TX_CHANNEL_BUSY);
}
else if (txAirDoneFlag)
{
txAirDoneFlag = false;
if (tx.jobIsAckResponse)
{
finishTransmission(SMS_EVENT_NONE);
}
else
{
smsSentAdd(tx.peerId, tx.text);
pushEvent(SMS_EVENT_TX_SENT);
if (smsOptionWaitForAck())
{
tx.state = SMS_TX_STATE_WAIT_ACK;
ticksTimerStart(&tx.ackTimer, SMS_WAIT_ACK_TIMEOUT_MS);
}
else
{
finishTransmission(SMS_EVENT_NONE);
}
}
}
break;
case SMS_TX_STATE_WAIT_ACK:
if (txDeliveredFlag)
{
txDeliveredFlag = false;
finishTransmission(SMS_EVENT_TX_DELIVERED);
}
else if (ticksTimerHasExpired(&tx.ackTimer))
{
finishTransmission(SMS_EVENT_TX_TIMEOUT);
}
break;
case SMS_TX_STATE_IDLE:
default:
txAirDoneFlag = false;
txRejectedFlag = false;
txDeliveredFlag = false;
break;
}
}

View file

@ -0,0 +1,323 @@
/*
* Copyright (C) 2026-today Marcus Kida, DK1DA
*
*
* Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions
* are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer
* in the documentation and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived
* from this software without specific prior written permission.
*
* 4. Use of this source code or binary releases for commercial purposes is strictly forbidden. This includes, without limitation,
* incorporation in a commercial product or incorporation into a product or project which allows commercial use.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
*/
#include <string.h>
#include "functions/smsStorage.h"
#include "functions/smsCore.h"
#include "functions/ticks.h"
#include "functions/trx.h"
#include "hardware/SPI_Flash.h"
#include "hardware/HR-C6000.h"
#define SMS_STORAGE_MAGIC 0x4D53444F // 'ODSM'
#define SMS_STORAGE_VERSION 2
#define SMS_STORAGE_DEBOUNCE_MS 1500
typedef struct __attribute__((packed))
{
uint32_t magic;
uint16_t version;
uint16_t imageSize;
uint8_t inboxCount;
uint8_t sentCount;
uint8_t quickTextCount;
uint8_t options;
uint32_t writeCounter;
uint32_t crc; // over everything after this field
smsRecord_t inbox[SMS_INBOX_SLOTS];
smsRecord_t sent[SMS_SENT_SLOTS];
char quickTexts[SMS_QUICKTEXT_SLOTS][SMS_MAX_TEXT_LENGTH + 1];
} smsStorageImage_t;
// SPI_Flash_write() can span at most two 4KB sectors, and dmrDataCRC32()
// consumes byte-swapped pairs, so the image must stay <= 8KB and even sized
typedef char assert_smsImageFitsTwoSectors[(sizeof(smsStorageImage_t) <= 8192) ? 1 : -1];
typedef char assert_smsImageSizeIsEven[((sizeof(smsStorageImage_t) % 2) == 0) ? 1 : -1];
static __attribute__((section(".ccmram"))) smsStorageImage_t storageImage;
static bool storageDirty = false;
static ticksTimer_t debounceTimer = { 0, 0 };
static const char *defaultQuickTexts[] =
{
"QRZ?",
"QSL 73",
"ON MY WAY",
"CALL ME",
"BACK IN 5 MIN"
};
static uint32_t computeImageCRC(void)
{
const uint8_t *afterCRC = ((const uint8_t *)&storageImage.crc) + sizeof(storageImage.crc);
uint16_t length = sizeof(smsStorageImage_t) - (afterCRC - (const uint8_t *)&storageImage);
return dmrDataCRC32(afterCRC, length);
}
static void formatStorage(void)
{
memset(&storageImage, 0, sizeof(storageImage));
storageImage.magic = SMS_STORAGE_MAGIC;
storageImage.version = SMS_STORAGE_VERSION;
storageImage.imageSize = sizeof(smsStorageImage_t);
storageImage.options = (SMS_OPTION_WAIT_FOR_ACK | SMS_OPTION_OWN_ID_ONLY);
storageImage.quickTextCount = (sizeof(defaultQuickTexts) / sizeof(defaultQuickTexts[0]));
for (uint8_t i = 0; i < storageImage.quickTextCount; i++)
{
strncpy(storageImage.quickTexts[i], defaultQuickTexts[i], SMS_MAX_TEXT_LENGTH);
}
storageDirty = true;
ticksTimerStart(&debounceTimer, SMS_STORAGE_DEBOUNCE_MS);
}
void smsStorageInit(void)
{
SPI_Flash_read(SMS_STORAGE_FLASH_ADDRESS, (uint8_t *)&storageImage, sizeof(storageImage));
if ((storageImage.magic != SMS_STORAGE_MAGIC) ||
(storageImage.version != SMS_STORAGE_VERSION) ||
(storageImage.imageSize != sizeof(smsStorageImage_t)) ||
(storageImage.crc != computeImageCRC()))
{
formatStorage();
}
}
void smsStorageMarkDirty(void)
{
storageDirty = true;
ticksTimerStart(&debounceTimer, SMS_STORAGE_DEBOUNCE_MS);
}
void smsStorageTick(void)
{
if ((storageDirty == false) || (ticksTimerHasExpired(&debounceTimer) == false))
{
return;
}
// Writing takes ~100ms (2 sector erases + programming), so keep it away
// from live DMR traffic and in-flight SMS work
if (trxTransmissionEnabled || trxIsTransmitting ||
((trxGetMode() == RADIO_MODE_DIGITAL) && (slotState != DMR_STATE_IDLE)) ||
(smsCoreIsIdle() == false))
{
ticksTimerStart(&debounceTimer, SMS_STORAGE_DEBOUNCE_MS);
return;
}
storageImage.writeCounter++;
storageImage.crc = computeImageCRC();
if (SPI_Flash_write(SMS_STORAGE_FLASH_ADDRESS, (uint8_t *)&storageImage, sizeof(storageImage)))
{
storageDirty = false;
}
else
{
ticksTimerStart(&debounceTimer, SMS_STORAGE_DEBOUNCE_MS); // retry later
}
}
//
// Generic record-list helpers shared by inbox and sent box
//
static bool recordListAdd(smsRecord_t *records, uint8_t slots, uint8_t *count, uint32_t peerId, const char *text, uint8_t flags)
{
if ((text == NULL) || (text[0] == 0))
{
return false;
}
// shift down, newest lives at index 0
uint8_t used = *count;
if (used > (slots - 1))
{
used = slots - 1;
}
memmove(&records[1], &records[0], used * sizeof(smsRecord_t));
records[0].peerId = peerId;
records[0].flags = flags;
strncpy(records[0].text, text, SMS_MAX_TEXT_LENGTH);
records[0].text[SMS_MAX_TEXT_LENGTH] = 0;
if (*count < slots)
{
(*count)++;
}
smsStorageMarkDirty();
return true;
}
static bool recordListDelete(smsRecord_t *records, uint8_t *count, uint8_t index)
{
if (index >= *count)
{
return false;
}
memmove(&records[index], &records[index + 1], (*count - index - 1) * sizeof(smsRecord_t));
(*count)--;
memset(&records[*count], 0, sizeof(smsRecord_t));
smsStorageMarkDirty();
return true;
}
uint8_t smsInboxCount(void)
{
return storageImage.inboxCount;
}
const smsRecord_t *smsInboxGet(uint8_t index)
{
return ((index < storageImage.inboxCount) ? &storageImage.inbox[index] : NULL);
}
bool smsInboxAdd(uint32_t srcId, const char *text)
{
return recordListAdd(storageImage.inbox, SMS_INBOX_SLOTS, &storageImage.inboxCount, srcId, text, SMS_RECORD_FLAG_UNREAD);
}
bool smsInboxDelete(uint8_t index)
{
return recordListDelete(storageImage.inbox, &storageImage.inboxCount, index);
}
void smsInboxMarkRead(uint8_t index)
{
if ((index < storageImage.inboxCount) && (storageImage.inbox[index].flags & SMS_RECORD_FLAG_UNREAD))
{
storageImage.inbox[index].flags &= ~SMS_RECORD_FLAG_UNREAD;
smsStorageMarkDirty();
}
}
uint8_t smsInboxUnreadCount(void)
{
uint8_t unread = 0;
for (uint8_t i = 0; i < storageImage.inboxCount; i++)
{
if (storageImage.inbox[i].flags & SMS_RECORD_FLAG_UNREAD)
{
unread++;
}
}
return unread;
}
uint8_t smsSentCount(void)
{
return storageImage.sentCount;
}
const smsRecord_t *smsSentGet(uint8_t index)
{
return ((index < storageImage.sentCount) ? &storageImage.sent[index] : NULL);
}
bool smsSentAdd(uint32_t dstId, const char *text)
{
return recordListAdd(storageImage.sent, SMS_SENT_SLOTS, &storageImage.sentCount, dstId, text, 0);
}
bool smsSentDelete(uint8_t index)
{
return recordListDelete(storageImage.sent, &storageImage.sentCount, index);
}
uint8_t smsQuickTextCount(void)
{
return storageImage.quickTextCount;
}
const char *smsQuickTextGet(uint8_t index)
{
return ((index < storageImage.quickTextCount) ? storageImage.quickTexts[index] : NULL);
}
bool smsQuickTextSet(uint8_t index, const char *text)
{
if ((text == NULL) || (text[0] == 0) || (index >= SMS_QUICKTEXT_SLOTS) || (index > storageImage.quickTextCount))
{
return false;
}
strncpy(storageImage.quickTexts[index], text, SMS_MAX_TEXT_LENGTH);
storageImage.quickTexts[index][SMS_MAX_TEXT_LENGTH] = 0;
if (index == storageImage.quickTextCount)
{
storageImage.quickTextCount++;
}
smsStorageMarkDirty();
return true;
}
bool smsQuickTextDelete(uint8_t index)
{
if (index >= storageImage.quickTextCount)
{
return false;
}
memmove(&storageImage.quickTexts[index], &storageImage.quickTexts[index + 1],
(storageImage.quickTextCount - index - 1) * sizeof(storageImage.quickTexts[0]));
storageImage.quickTextCount--;
memset(&storageImage.quickTexts[storageImage.quickTextCount], 0, sizeof(storageImage.quickTexts[0]));
smsStorageMarkDirty();
return true;
}
uint8_t smsStorageGetOptions(void)
{
return storageImage.options;
}
void smsStorageSetOptions(uint8_t options)
{
if (storageImage.options != options)
{
storageImage.options = options;
smsStorageMarkDirty();
}
}

View file

@ -98,6 +98,14 @@ const int16_t MELODY_PRIVATE_CALL[] = {
880, DIT_LENGTH
-1, -1 };// Morse letters PC for Private Call
// Rising two-tone chime, distinct from the private call morse alert
const int16_t MELODY_SMS_RX[] = {
1047, 80,
0, 40,
1319, 80,
0, 40,
1568, 160,
-1, -1 };
const int16_t MELODY_KEY_BEEP[] = { 600, 60, -1, -1 };
const int16_t MELODY_KEY_LONG_BEEP[] = { 880, 60, -1, -1 };
/* These melodies are not currently used

View file

@ -40,6 +40,7 @@
#include "interfaces/interrupts.h"
#include "functions/rxPowerSaving.h"
#include "functions/ticks.h"
#include "functions/smsCore.h"
#include "interfaces/gps.h"
#if defined(PLATFORM_MD9600) || defined(PLATFORM_MD380) || defined(PLATFORM_MDUV380) || defined(PLATFORM_RT84_DM1701) || defined(PLATFORM_MD2017)
#include "hardware/radioHardwareInterface.h"
@ -253,6 +254,9 @@ static struct
int hotspotPostponedFrameHandling;
char talkAliasText[33];
uint8_t talkAliasLocation[7];
volatile bool smsActive; // an SMS air job is being streamed instead of voice
volatile uint8_t smsFrameIndex;
const smsAirJob_t *volatile smsJob;
} hrc = {
.hasEncodedAudio = false,
.hasAudioData = false,
@ -299,7 +303,10 @@ static struct
.wakeTriesCount = 0,
.hotspotPostponedFrameHandling = 0,
.talkAliasText = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 },
.talkAliasLocation = { 0, 0, 0, 0, 0, 0, 0}
.talkAliasLocation = { 0, 0, 0, 0, 0, 0, 0},
.smsActive = false,
.smsFrameIndex = 0,
.smsJob = NULL
};
volatile int slotState = DMR_STATE_IDLE;
@ -826,6 +833,14 @@ void PORTC_IRQHandler(void)
#endif
static inline void hrc6000SysSendRejectedInt(void)
{
if (hrc.smsActive)
{
// The channel became busy before our data went out
hrc.smsActive = false;
hrc.transmissionEnabled = false;
trxTransmissionEnabled = false;
smsTxNotifyRejected();
}
}
static inline void hrc6000SysSendStartInt(void)
@ -895,6 +910,11 @@ static inline void hrc6000SysSendEndInt(void)
In MSK mode, there is no substate interrupt.
*/
if (reg_0x86 & 0x08) // confirmed-data feedback packet was received
{
smsTxNotifyHwAck();
}
}
static inline void hrc6000SysPostAccessInt(void)
@ -1040,6 +1060,24 @@ static inline void hrc6000SysReceivedDataInt(void)
hrc.skipCount--;
}
// DMR short data (SMS): data header, rate-1/2 and rate-3/4 data bursts.
// The chip has already BPTC/FEC decoded the burst; the logical payload
// is read from the LC RAM (rate-3/4 blocks carry 18 bytes, everything
// else 12).
if (hrc6000CrcIsValid() && (rxPrivacyIndicator == 0) && (rxSyncClass == SYNC_CLASS_DATA) &&
((rxDataType == DMR_DATA_TYPE_DATA_HEADER) || (rxDataType == DMR_DATA_TYPE_RATE_12_DATA) || (rxDataType == DMR_DATA_TYPE_RATE_34_DATA)) &&
(settingsUsbMode != USB_MODE_HOTSPOT) && (hrc.transmissionEnabled == false) &&
hrc6000CheckColourCodeFilter())
{
uint8_t smsFrame[DMR_DATA_RATE34_BLOCK_LENGTH];
uint8_t burstLength = ((rxDataType == DMR_DATA_TYPE_RATE_34_DATA) ? DMR_DATA_RATE34_BLOCK_LENGTH : LC_DATA_LENGTH);
if (SPI0ReadPageRegByteArray(0x02, 0x00, smsFrame, burstLength) == kStatus_Success)
{
smsRxProcessDataFrame(rxDataType, smsFrame);
}
}
// Check for correct received packet
if (hrc6000CrcIsValid() && (rxPrivacyIndicator == 0) && (slotState < DMR_STATE_TX_START_1))
{
@ -1470,6 +1508,48 @@ static void hrc6000TransitionToTx(void)
hrc.txSequence = 0;
}
// Queue the next SMS burst for the upcoming timeslot: 12-byte logical
// payload into the LC RAM, its DMR data type into register 0x50. The chip
// performs the BPTC/FEC encoding. Once the last frame is queued the
// transmission is ended the same way as a released PTT.
static void hrc6000SendSmsFrame(void)
{
const smsAirJob_t *job = hrc.smsJob;
uint8_t dataType;
if ((job == NULL) || (hrc.smsFrameIndex >= job->frameCount))
{
return;
}
// register 0x50 value: high nibble = DMR data type, low bits per datasheet
if (hrc.smsFrameIndex < job->preambleCount)
{
dataType = 0x30; // CSBK
}
else if (hrc.smsFrameIndex == job->preambleCount)
{
dataType = 0x64; // data header
}
else
{
dataType = 0x70; // rate-1/2 data
}
SPI0WritePageRegByteArray(0x02, 0x00, (uint8_t *)job->frames[hrc.smsFrameIndex], LC_DATA_LENGTH);
SPI0WritePageRegByte(0x04, 0x41, 0x80); // Transmit during next Timeslot
SPI0WritePageRegByte(0x04, 0x50, dataType);
hrc.smsFrameIndex++;
if (hrc.smsFrameIndex >= job->frameCount)
{
// all bursts are queued, terminate (the terminator frame is sent by the TX_END path)
hrc.transmissionEnabled = false;
trxTransmissionEnabled = false;
}
}
//used as a delayed callback to allow time for the final Tx burst and then simulate the final Rx Interrupt and return to receive.
void hrc6000TxBurstCallback(void)
{
@ -1712,9 +1792,16 @@ void hrc6000TimeslotInterruptHandler(void)
case DMR_STATE_TX_START_1: // Start TX (second step)
LedWrite(LED_RED, 1); // for repeater wakeup
if (hrc.smsActive)
{
hrc6000SendSmsFrame();
}
else
{
hrc6000SendPcOrTgLCHeader();
SPI0WritePageRegByte(0x04, 0x41, 0x80); // Transmit during next Timeslot
SPI0WritePageRegByte(0x04, 0x50, 0x10); // Set Data Type to 0001 (Voice LC Header), Data, LCSS=00
}
trxIsTransmitting = true;
slotState = DMR_STATE_TX_START_2;
break;
@ -1725,8 +1812,15 @@ void hrc6000TimeslotInterruptHandler(void)
break;
case DMR_STATE_TX_START_3: // Start TX (fourth step)
if (hrc.smsActive)
{
hrc6000SendSmsFrame();
}
else
{
SPI0WritePageRegByte(0x04, 0x41, 0x80); // Transmit during Next Timeslot
SPI0WritePageRegByte(0x04, 0x50, 0x10); // Set Data Type to 0001 (Voice LC Header), Data, LCSS=00
}
slotState = DMR_STATE_TX_START_4;
break;
@ -1734,7 +1828,7 @@ void hrc6000TimeslotInterruptHandler(void)
SPI0WritePageRegByte(0x04, 0x41, 0x00); // Do nothing on the next TS
slotState = DMR_STATE_TX_START_5;
if (settingsUsbMode != USB_MODE_HOTSPOT)
if ((settingsUsbMode != USB_MODE_HOTSPOT) && (hrc.smsActive == false))
{
hrc.ambeBufferCount = 0;
hrc.deferredUpdateBufferOutPtr = deferredUpdateBuffer;
@ -1744,8 +1838,15 @@ void hrc6000TimeslotInterruptHandler(void)
break;
case DMR_STATE_TX_START_5: // Start TX (sixth step)
if (hrc.smsActive)
{
hrc6000SendSmsFrame();
}
else
{
SPI0WritePageRegByte(0x04, 0x41, 0x80); // Transmit during next Timeslot
SPI0WritePageRegByte(0x04, 0x50, 0x10); // Set Data Type to 0001 (Voice LC Header), Data, LCSS=00
}
hrc.TAPhase = 0;
slotState = DMR_STATE_TX_1;
break;
@ -1763,6 +1864,15 @@ void hrc6000TimeslotInterruptHandler(void)
break;
case DMR_STATE_TX_2: // Ongoing TX (active timeslot)
if (hrc.smsActive)
{
// One data burst per active timeslot; no audio involved, and
// txSequence stays at 0 so DMR_STATE_TX_1 can terminate cleanly
hrc6000SendSmsFrame();
slotState = DMR_STATE_TX_1;
break;
}
if (hrc.transmissionEnabled)
{
if (settingsUsbMode == USB_MODE_HOTSPOT)
@ -1826,7 +1936,9 @@ void hrc6000TimeslotInterruptHandler(void)
break;
case DMR_STATE_TX_END_1: // Stop TX (first step)
if (getCurrentTATxFlag() != TA_TX_OFF)
// After an SMS the LC RAM still holds the last data block, so a
// valid LC has to be rewritten for the terminator frame
if (hrc.smsActive || (getCurrentTATxFlag() != TA_TX_OFF))
{
hrc6000SendPcOrTgLCHeader();
}
@ -1837,6 +1949,13 @@ void hrc6000TimeslotInterruptHandler(void)
break;
case DMR_STATE_TX_END_2: // Stop TX (second step)
if (hrc.smsActive)
{
hrc.smsActive = false;
hrc.smsJob = NULL;
LedWrite(LED_RED, 0); // no TX screen is handling the LED for SMS
smsTxNotifyAirDone();
}
// Need to hold on this TS after Tx ends otherwise if DMR Mon TS filtering is disabled the radio may switch timeslot
dmrMonitorCapturedTS = hrc.tsLockedTS = trxGetDMRTimeSlot();
hrc.dmrMonitorCapturedTimeout = nonVolatileSettings.dmrCaptureTimeout * 1000;
@ -2017,6 +2136,9 @@ static void hrc6000InitDigitalState(void)
hrc.interruptTimeout = 0;
slotState = DMR_STATE_IDLE;
trxIsTransmitting = false;
hrc.smsActive = false;
hrc.smsJob = NULL;
hrc.smsFrameIndex = 0;
hrc.tickCount = 0;
hrc.skipCount = 0;
hrc.tsAgreed = 0;
@ -2761,6 +2883,49 @@ bool HRC6000IRQHandlerIsRunning(void)
return hrc.inIRQHandler;
}
// Start streaming the prepared SMS air job. The regular DMR TX machinery
// (including the repeater wake sequence in RMO) is reused; only the burst
// contents differ from a voice call.
bool HRC6000StartSmsTransmission(void)
{
const smsAirJob_t *job = smsGetPendingAirJob();
if ((job == NULL) || (job->frameCount == 0) ||
trxTransmissionEnabled || trxIsTransmitting ||
(trxGetMode() != RADIO_MODE_DIGITAL) ||
(settingsUsbMode == USB_MODE_HOTSPOT) ||
(slotState != DMR_STATE_IDLE))
{
return false;
}
if (hrc.isWaking == WAKING_MODE_FAILED)
{
hrc.isWaking = WAKING_MODE_NONE; // recover from an earlier failed repeater wake
}
hrc.smsJob = job;
hrc.smsFrameIndex = 0;
hrc.smsActive = true;
smsAirJobStarted();
trxEnableTransmission();
return true;
}
bool HRC6000IsSendingSMS(void)
{
return hrc.smsActive;
}
void HRC6000CancelSmsTransmission(void)
{
hrc.smsActive = false;
hrc.smsJob = NULL;
hrc.smsFrameIndex = 0;
}
bool HRC6000HasGotSync(void)
{
return (hrc.timeCode != -1);

View file

@ -0,0 +1,317 @@
/*
* Copyright (C) 2026-today Marcus Kida, DK1DA
*
*
* Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions
* are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer
* in the documentation and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived
* from this software without specific prior written permission.
*
* 4. Use of this source code or binary releases for commercial purposes is strictly forbidden. This includes, without limitation,
* incorporation in a commercial product or incorporation into a product or project which allows commercial use.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
*/
#include <stdlib.h>
#include "user_interface/uiGlobals.h"
#include "user_interface/menuSystem.h"
#include "user_interface/uiLocalisation.h"
#include "user_interface/uiUtilities.h"
#include "io/keyboard.h"
#include "functions/smsCore.h"
#include "functions/smsStorage.h"
#define DEST_DIGITS_MAX 8 // 16777215 is the largest 24-bit DMR ID
#define TEXT_CHARS_PER_LINE (DISPLAY_SIZE_X / 8)
#define TEXT_VISIBLE_LINES 5
#define TEXT_AREA_Y (FONT_SIZE_3_HEIGHT * 2)
enum { FIELD_DESTINATION = 0, FIELD_TEXT };
static char composeText[SMS_MAX_TEXT_LENGTH + 1];
static char destDigits[DEST_DIGITS_MAX + 1];
static int textPos = 0;
static int focusedField = FIELD_TEXT;
static int quickTextEditSlot = -1;
// prefill is written straight into the compose buffers, consumed on first run
static bool prefillPending = false;
static int pendingQuickTextEditSlot = -1;
static menuStatus_t menuSMSComposeExitCode = MENU_STATUS_SUCCESS;
static void updateScreen(void);
static void handleEvent(uiEvent_t *ev);
void menuSMSComposePrefill(uint32_t dstId, const char *text)
{
destDigits[0] = 0;
composeText[0] = 0;
if (dstId != 0)
{
snprintf(destDigits, sizeof(destDigits), "%u", (unsigned int)dstId);
}
if (text != NULL)
{
strncpy(composeText, text, SMS_MAX_TEXT_LENGTH);
composeText[SMS_MAX_TEXT_LENGTH] = 0;
}
prefillPending = true;
}
void menuSMSComposeSetQuickTextEditTarget(int slotIndex)
{
pendingQuickTextEditSlot = slotIndex;
}
menuStatus_t menuSMSCompose(uiEvent_t *ev, bool isFirstRun)
{
if (isFirstRun)
{
quickTextEditSlot = pendingQuickTextEditSlot;
pendingQuickTextEditSlot = -1;
if (quickTextEditSlot >= 0)
{
const char *quickText = smsQuickTextGet(quickTextEditSlot);
composeText[0] = 0;
destDigits[0] = 0;
if (quickText != NULL)
{
strncpy(composeText, quickText, SMS_MAX_TEXT_LENGTH);
composeText[SMS_MAX_TEXT_LENGTH] = 0;
}
focusedField = FIELD_TEXT;
}
else
{
if (prefillPending == false)
{
composeText[0] = 0;
destDigits[0] = 0;
}
focusedField = ((destDigits[0] == 0) ? FIELD_DESTINATION : FIELD_TEXT);
}
textPos = strlen(composeText);
prefillPending = false;
updateScreen();
return (MENU_STATUS_INPUT_TYPE | MENU_STATUS_SUCCESS);
}
menuSMSComposeExitCode = MENU_STATUS_SUCCESS;
if (ev->hasEvent)
{
handleEvent(ev);
}
return menuSMSComposeExitCode;
}
static void updateScreen(void)
{
char lineBuffer[TEXT_CHARS_PER_LINE + 1];
displayClearBuf();
menuDisplayTitle((quickTextEditSlot >= 0) ? currentLanguage->quick_text : currentLanguage->new_message);
keypadAlphaEnable = (focusedField == FIELD_TEXT);
// destination line (hidden when editing a quick text template)
if (quickTextEditSlot < 0)
{
snprintf(lineBuffer, sizeof(lineBuffer), "%s:%s%s",
currentLanguage->send_to, destDigits,
((focusedField == FIELD_DESTINATION) ? "_" : ""));
displayPrintAt(0, FONT_SIZE_3_HEIGHT, lineBuffer, FONT_SIZE_3);
}
// text area with a scrolling window that follows the cursor
int textLength = strlen(composeText);
int cursorLine = textPos / TEXT_CHARS_PER_LINE;
int firstLine = ((cursorLine >= TEXT_VISIBLE_LINES) ? (cursorLine - TEXT_VISIBLE_LINES + 1) : 0);
for (int line = 0; line < TEXT_VISIBLE_LINES; line++)
{
int offset = (firstLine + line) * TEXT_CHARS_PER_LINE;
if (offset > textLength)
{
break;
}
int chunk = textLength - offset;
if (chunk > TEXT_CHARS_PER_LINE)
{
chunk = TEXT_CHARS_PER_LINE;
}
memcpy(lineBuffer, &composeText[offset], chunk);
lineBuffer[chunk] = 0;
displayPrintAt(0, TEXT_AREA_Y + (line * FONT_SIZE_3_HEIGHT), lineBuffer, FONT_SIZE_3);
}
if (focusedField == FIELD_TEXT)
{
int cursorX = (textPos % TEXT_CHARS_PER_LINE) * 8;
int cursorY = TEXT_AREA_Y + ((cursorLine - firstLine + 1) * FONT_SIZE_3_HEIGHT) - 2;
displayDrawFastHLine(cursorX, cursorY, 8, true);
}
// footer: remaining characters
snprintf(lineBuffer, sizeof(lineBuffer), "%d", SMS_MAX_TEXT_LENGTH - textLength);
displayPrintAt(DISPLAY_SIZE_X - (strlen(lineBuffer) * 8), DISPLAY_SIZE_Y - FONT_SIZE_3_HEIGHT, lineBuffer, FONT_SIZE_3);
displayRender();
}
static void sendOrSave(void)
{
if (quickTextEditSlot >= 0)
{
if (composeText[0] != 0)
{
smsQuickTextSet(quickTextEditSlot, composeText);
keypadAlphaEnable = false;
menuSystemPopPreviousMenu();
}
else
{
menuSMSComposeExitCode |= MENU_STATUS_ERROR;
}
return;
}
uint32_t dstId = (uint32_t)strtoul(destDigits, NULL, 10);
if ((dstId == 0) || (composeText[0] == 0))
{
menuSMSComposeExitCode |= MENU_STATUS_ERROR;
return;
}
if (smsSendMessage(dstId, composeText))
{
keypadAlphaEnable = false;
menuSystemPopAllAndDisplayRootMenu(); // progress is reported via notifications
}
else
{
menuSMSComposeExitCode |= MENU_STATUS_ERROR; // busy or not in DMR mode
}
}
static void handleEvent(uiEvent_t *ev)
{
if (ev->events & KEY_EVENT)
{
if (KEYCHECK_SHORTUP(ev->keys, KEY_RED))
{
keypadAlphaEnable = false;
menuSystemPopPreviousMenu();
return;
}
else if (KEYCHECK_SHORTUP(ev->keys, KEY_GREEN))
{
sendOrSave();
return;
}
else if (KEYCHECK_PRESS(ev->keys, KEY_UP) && (quickTextEditSlot < 0))
{
focusedField = FIELD_DESTINATION;
updateScreen();
return;
}
else if (KEYCHECK_PRESS(ev->keys, KEY_DOWN))
{
focusedField = FIELD_TEXT;
updateScreen();
return;
}
if (focusedField == FIELD_DESTINATION)
{
if (KEYCHECK_PRESS(ev->keys, KEY_LEFT))
{
int len = strlen(destDigits);
if (len > 0)
{
destDigits[len - 1] = 0;
}
updateScreen();
}
else
{
int keyval = menuGetKeypadKeyValue(ev, true);
if ((keyval != 99) && (strlen(destDigits) < DEST_DIGITS_MAX))
{
char digit[2] = { (char)('0' + keyval), 0 };
strcat(destDigits, digit);
updateScreen();
}
}
}
else // FIELD_TEXT
{
if (KEYCHECK_PRESS(ev->keys, KEY_LEFT))
{
moveCursorLeftInString(composeText, &textPos, BUTTONCHECK_DOWN(ev, BUTTON_SK2));
updateScreen();
}
else if (KEYCHECK_PRESS(ev->keys, KEY_RIGHT))
{
moveCursorRightInString(composeText, &textPos, SMS_MAX_TEXT_LENGTH, BUTTONCHECK_DOWN(ev, BUTTON_SK2));
updateScreen();
}
else if ((ev->keys.event & (KEY_MOD_PREVIEW | KEY_MOD_PRESS)) && (ev->keys.key >= 32) && (ev->keys.key <= 126))
{
if (textPos < SMS_MAX_TEXT_LENGTH)
{
bool atEnd = (composeText[textPos] == 0);
composeText[textPos] = ev->keys.key;
if (atEnd)
{
composeText[textPos + 1] = 0;
}
if ((ev->keys.event & KEY_MOD_PRESS) && (textPos < (SMS_MAX_TEXT_LENGTH - 1)))
{
textPos++;
}
updateScreen();
}
}
}
}
}

View file

@ -0,0 +1,366 @@
/*
* Copyright (C) 2026-today Marcus Kida, DK1DA
*
*
* Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions
* are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer
* in the documentation and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived
* from this software without specific prior written permission.
*
* 4. Use of this source code or binary releases for commercial purposes is strictly forbidden. This includes, without limitation,
* incorporation in a commercial product or incorporation into a product or project which allows commercial use.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
*/
/*
* Message list (inbox and sent box) and single-message view.
* One implementation serves MENU_SMS_INBOX, MENU_SMS_SENT and MENU_SMS_VIEW.
*/
#include "user_interface/uiGlobals.h"
#include "user_interface/menuSystem.h"
#include "user_interface/uiLocalisation.h"
#include "user_interface/uiUtilities.h"
#include "functions/smsCore.h"
#include "functions/smsStorage.h"
#define TEXT_CHARS_PER_LINE (DISPLAY_SIZE_X / 8)
#define VIEW_TEXT_LINES ((DISPLAY_SIZE_Y / FONT_SIZE_3_HEIGHT) - 2) // minus title and sender line
static bool viewIsSentBox = false;
static uint8_t viewIndex = 0;
static int viewScrollLine = 0;
static menuStatus_t menuSMSListExitCode = MENU_STATUS_SUCCESS;
static void updateListScreen(bool isSentBox);
static void updateViewScreen(void);
static void handleListEvent(uiEvent_t *ev, bool isSentBox);
static void handleViewEvent(uiEvent_t *ev);
static bool deleteMessageCallback(void);
void menuSMSListSetViewTarget(bool sentBox, uint8_t recordIndex)
{
viewIsSentBox = sentBox;
viewIndex = recordIndex;
viewScrollLine = 0;
}
static const smsRecord_t *getRecord(bool isSentBox, uint8_t index)
{
return (isSentBox ? smsSentGet(index) : smsInboxGet(index));
}
static uint8_t getRecordCount(bool isSentBox)
{
return (isSentBox ? smsSentCount() : smsInboxCount());
}
static void peerNameLookup(uint32_t peerId, char *buffer, size_t bufferLen)
{
char nameBuf[SCREEN_LINE_BUFFER_SIZE];
if (contactIDLookup(peerId, CONTACT_CALLTYPE_PC, nameBuf) == false)
{
dmrIdDataStruct_t record;
if (dmrIDLookup(peerId, &record))
{
strncpy(nameBuf, record.text, sizeof(nameBuf) - 1);
nameBuf[sizeof(nameBuf) - 1] = 0;
}
else
{
snprintf(nameBuf, sizeof(nameBuf), "%u", (unsigned int)peerId);
}
}
// only keep the callsign / first word for list display
char *space = strchr(nameBuf, ' ');
if (space != NULL)
{
*space = 0;
}
strncpy(buffer, nameBuf, bufferLen - 1);
buffer[bufferLen - 1] = 0;
}
menuStatus_t menuSMSList(uiEvent_t *ev, bool isFirstRun)
{
int currentMenu = menuSystemGetCurrentMenuNumber();
bool isView = (currentMenu == MENU_SMS_VIEW);
bool isSentBox = ((currentMenu == MENU_SMS_SENT) || (isView && viewIsSentBox));
if (isFirstRun)
{
if (isView)
{
if (viewIndex >= getRecordCount(viewIsSentBox))
{
menuSystemPopPreviousMenu();
return MENU_STATUS_SUCCESS;
}
if (viewIsSentBox == false)
{
smsInboxMarkRead(viewIndex);
}
updateViewScreen();
}
else
{
menuDataGlobal.numItems = getRecordCount(isSentBox);
menuDataGlobal.currentItemIndex = 0;
updateListScreen(isSentBox);
}
return (MENU_STATUS_LIST_TYPE | MENU_STATUS_SUCCESS);
}
menuSMSListExitCode = MENU_STATUS_SUCCESS;
if (isView)
{
// the record can vanish underneath us (deleted via the confirm box)
if (viewIndex >= getRecordCount(viewIsSentBox))
{
menuSystemPopPreviousMenu();
return MENU_STATUS_SUCCESS;
}
if (ev->hasEvent)
{
handleViewEvent(ev);
}
}
else
{
if (ev->hasEvent)
{
handleListEvent(ev, isSentBox);
}
}
return menuSMSListExitCode;
}
static void updateListScreen(bool isSentBox)
{
char entryBuffer[SCREEN_LINE_BUFFER_SIZE * 2];
char nameBuffer[SCREEN_LINE_BUFFER_SIZE];
int mNum;
displayClearBuf();
menuDisplayTitle(isSentBox ? currentLanguage->sent_box : currentLanguage->inbox);
if (menuDataGlobal.numItems == 0)
{
displayPrintCentered(DISPLAY_SIZE_Y / 2, currentLanguage->none, FONT_SIZE_3);
}
for (int i = MENU_START_ITERATION_VALUE; i <= MENU_END_ITERATION_VALUE; i++)
{
if (menuDataGlobal.numItems == 0)
{
break;
}
mNum = menuGetMenuOffset(menuDataGlobal.numItems, i);
if (mNum == MENU_OFFSET_BEFORE_FIRST_ENTRY)
{
continue;
}
else if (mNum == MENU_OFFSET_AFTER_LAST_ENTRY)
{
break;
}
const smsRecord_t *record = getRecord(isSentBox, (uint8_t)mNum);
if (record == NULL)
{
break;
}
peerNameLookup(record->peerId, nameBuffer, sizeof(nameBuffer));
snprintf(entryBuffer, sizeof(entryBuffer), "%s%s:%s",
((record->flags & SMS_RECORD_FLAG_UNREAD) ? "*" : ""), nameBuffer, record->text);
menuDisplayEntry(i, mNum, entryBuffer, 0, THEME_ITEM_FG_TEXT_INPUT, THEME_ITEM_COLOUR_NONE, THEME_ITEM_BG);
}
displayRender();
}
static void updateViewScreen(void)
{
char lineBuffer[TEXT_CHARS_PER_LINE + 1];
const smsRecord_t *record = getRecord(viewIsSentBox, viewIndex);
if (record == NULL)
{
return;
}
displayClearBuf();
peerNameLookup(record->peerId, lineBuffer, sizeof(lineBuffer));
menuDisplayTitle(lineBuffer);
snprintf(lineBuffer, sizeof(lineBuffer), "%s%u", (viewIsSentBox ? ">" : "<"), (unsigned int)record->peerId);
displayPrintAt(0, FONT_SIZE_3_HEIGHT, lineBuffer, FONT_SIZE_3);
int textLength = strlen(record->text);
int totalLines = (textLength + TEXT_CHARS_PER_LINE - 1) / TEXT_CHARS_PER_LINE;
if (viewScrollLine > (totalLines - VIEW_TEXT_LINES))
{
viewScrollLine = totalLines - VIEW_TEXT_LINES;
}
if (viewScrollLine < 0)
{
viewScrollLine = 0;
}
for (int line = 0; line < VIEW_TEXT_LINES; line++)
{
int offset = (viewScrollLine + line) * TEXT_CHARS_PER_LINE;
if (offset >= textLength)
{
break;
}
int chunk = textLength - offset;
if (chunk > TEXT_CHARS_PER_LINE)
{
chunk = TEXT_CHARS_PER_LINE;
}
memcpy(lineBuffer, &record->text[offset], chunk);
lineBuffer[chunk] = 0;
displayPrintAt(0, (FONT_SIZE_3_HEIGHT * 2) + (line * FONT_SIZE_3_HEIGHT), lineBuffer, FONT_SIZE_3);
}
displayRender();
}
static void handleListEvent(uiEvent_t *ev, bool isSentBox)
{
if ((ev->events & KEY_EVENT) == 0)
{
return;
}
if (KEYCHECK_SHORTUP(ev->keys, KEY_RED))
{
menuSystemPopPreviousMenu();
return;
}
else if (KEYCHECK_SHORTUP(ev->keys, KEY_GREEN))
{
if (menuDataGlobal.numItems > 0)
{
menuSMSListSetViewTarget(isSentBox, (uint8_t)menuDataGlobal.currentItemIndex);
menuSystemPushNewMenu(MENU_SMS_VIEW);
}
return;
}
else if (KEYCHECK_PRESS(ev->keys, KEY_DOWN))
{
menuSystemMenuIncrement(&menuDataGlobal.currentItemIndex, menuDataGlobal.numItems);
updateListScreen(isSentBox);
}
else if (KEYCHECK_PRESS(ev->keys, KEY_UP))
{
menuSystemMenuDecrement(&menuDataGlobal.currentItemIndex, menuDataGlobal.numItems);
updateListScreen(isSentBox);
}
}
static bool deleteMessageCallback(void)
{
if (uiDataGlobal.MessageBox.keyPressed == KEY_GREEN)
{
if (viewIsSentBox)
{
smsSentDelete(viewIndex);
}
else
{
smsInboxDelete(viewIndex);
}
}
return true;
}
static void handleViewEvent(uiEvent_t *ev)
{
if ((ev->events & KEY_EVENT) == 0)
{
return;
}
if (KEYCHECK_SHORTUP(ev->keys, KEY_RED))
{
if (BUTTONCHECK_DOWN(ev, BUTTON_SK2))
{
const smsRecord_t *record = getRecord(viewIsSentBox, viewIndex);
if (record != NULL)
{
snprintf(uiDataGlobal.MessageBox.message, MESSAGEBOX_MESSAGE_LEN_MAX, "%s", currentLanguage->delete_message);
uiDataGlobal.MessageBox.type = MESSAGEBOX_TYPE_INFO;
uiDataGlobal.MessageBox.buttons = MESSAGEBOX_BUTTONS_YESNO;
uiDataGlobal.MessageBox.decoration = MESSAGEBOX_DECORATION_FRAME;
uiDataGlobal.MessageBox.validatorCallback = deleteMessageCallback;
menuSystemPushNewMenu(UI_MESSAGE_BOX);
}
}
else
{
menuSystemPopPreviousMenu();
}
return;
}
else if (KEYCHECK_SHORTUP(ev->keys, KEY_GREEN))
{
const smsRecord_t *record = getRecord(viewIsSentBox, viewIndex);
if (record != NULL)
{
// inbox: reply to the sender; sent box: re-edit and resend
menuSMSComposePrefill(record->peerId, (viewIsSentBox ? record->text : NULL));
menuSystemPushNewMenu(MENU_SMS_COMPOSE);
}
return;
}
else if (KEYCHECK_PRESS(ev->keys, KEY_DOWN))
{
viewScrollLine++;
updateViewScreen();
}
else if (KEYCHECK_PRESS(ev->keys, KEY_UP))
{
viewScrollLine--;
updateViewScreen();
}
}

View file

@ -0,0 +1,146 @@
/*
* Copyright (C) 2026-today Marcus Kida, DK1DA
*
*
* Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions
* are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer
* in the documentation and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived
* from this software without specific prior written permission.
*
* 4. Use of this source code or binary releases for commercial purposes is strictly forbidden. This includes, without limitation,
* incorporation in a commercial product or incorporation into a product or project which allows commercial use.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
*/
#include "user_interface/uiGlobals.h"
#include "user_interface/menuSystem.h"
#include "user_interface/uiLocalisation.h"
#include "user_interface/uiUtilities.h"
#include "functions/smsCore.h"
#include "functions/smsStorage.h"
enum { OPTION_WAIT_FOR_ACK = 0, OPTION_OWN_ID_ONLY, NUM_SMS_OPTIONS };
static menuStatus_t menuSMSOptionsExitCode = MENU_STATUS_SUCCESS;
static void updateScreen(void);
static void handleEvent(uiEvent_t *ev);
menuStatus_t menuSMSOptions(uiEvent_t *ev, bool isFirstRun)
{
if (isFirstRun)
{
menuDataGlobal.numItems = NUM_SMS_OPTIONS;
menuDataGlobal.currentItemIndex = 0;
updateScreen();
return (MENU_STATUS_LIST_TYPE | MENU_STATUS_SUCCESS);
}
menuSMSOptionsExitCode = MENU_STATUS_SUCCESS;
if (ev->hasEvent)
{
handleEvent(ev);
}
return menuSMSOptionsExitCode;
}
static void updateScreen(void)
{
char entryBuffer[SCREEN_LINE_BUFFER_SIZE * 2];
int mNum;
displayClearBuf();
menuDisplayTitle(currentLanguage->options);
for (int i = MENU_START_ITERATION_VALUE; i <= MENU_END_ITERATION_VALUE; i++)
{
mNum = menuGetMenuOffset(NUM_SMS_OPTIONS, i);
if (mNum == MENU_OFFSET_BEFORE_FIRST_ENTRY)
{
continue;
}
else if (mNum == MENU_OFFSET_AFTER_LAST_ENTRY)
{
break;
}
switch (mNum)
{
case OPTION_WAIT_FOR_ACK:
snprintf(entryBuffer, sizeof(entryBuffer), "%s:%s",
currentLanguage->sms_wait_for_ack, (smsOptionWaitForAck() ? currentLanguage->on : currentLanguage->off));
break;
case OPTION_OWN_ID_ONLY:
snprintf(entryBuffer, sizeof(entryBuffer), "%s:%s",
currentLanguage->sms_own_id_only, (smsOptionOwnIdOnly() ? currentLanguage->on : currentLanguage->off));
break;
default:
entryBuffer[0] = 0;
break;
}
menuDisplayEntry(i, mNum, entryBuffer, 0, THEME_ITEM_FG_TEXT_INPUT, THEME_ITEM_COLOUR_NONE, THEME_ITEM_BG);
}
displayRender();
}
static void toggleCurrentOption(void)
{
switch (menuDataGlobal.currentItemIndex)
{
case OPTION_WAIT_FOR_ACK:
smsOptionSetWaitForAck(smsOptionWaitForAck() == false);
break;
case OPTION_OWN_ID_ONLY:
smsOptionSetOwnIdOnly(smsOptionOwnIdOnly() == false);
break;
}
}
static void handleEvent(uiEvent_t *ev)
{
if ((ev->events & KEY_EVENT) == 0)
{
return;
}
if (KEYCHECK_SHORTUP(ev->keys, KEY_RED) || KEYCHECK_SHORTUP(ev->keys, KEY_GREEN))
{
menuSystemPopPreviousMenu();
return;
}
else if (KEYCHECK_PRESS(ev->keys, KEY_LEFT) || KEYCHECK_PRESS(ev->keys, KEY_RIGHT))
{
toggleCurrentOption();
updateScreen();
}
else if (KEYCHECK_PRESS(ev->keys, KEY_DOWN))
{
menuSystemMenuIncrement(&menuDataGlobal.currentItemIndex, NUM_SMS_OPTIONS);
updateScreen();
}
else if (KEYCHECK_PRESS(ev->keys, KEY_UP))
{
menuSystemMenuDecrement(&menuDataGlobal.currentItemIndex, NUM_SMS_OPTIONS);
updateScreen();
}
}

View file

@ -0,0 +1,185 @@
/*
* Copyright (C) 2026-today Marcus Kida, DK1DA
*
*
* Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions
* are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer
* in the documentation and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived
* from this software without specific prior written permission.
*
* 4. Use of this source code or binary releases for commercial purposes is strictly forbidden. This includes, without limitation,
* incorporation in a commercial product or incorporation into a product or project which allows commercial use.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
*/
/*
* Quick text templates: pick one to start a message, SK2+GREEN edits the
* slot, SK2+RED deletes it. The last entry creates a new template while
* free slots remain.
*/
#include "user_interface/uiGlobals.h"
#include "user_interface/menuSystem.h"
#include "user_interface/uiLocalisation.h"
#include "user_interface/uiUtilities.h"
#include "functions/smsCore.h"
#include "functions/smsStorage.h"
static menuStatus_t menuSMSQuickTextExitCode = MENU_STATUS_SUCCESS;
static void updateScreen(void);
static void handleEvent(uiEvent_t *ev);
static bool deleteQuickTextCallback(void);
static bool hasNewEntry(void)
{
return (smsQuickTextCount() < SMS_QUICKTEXT_SLOTS);
}
static int totalEntries(void)
{
return (smsQuickTextCount() + (hasNewEntry() ? 1 : 0));
}
menuStatus_t menuSMSQuickText(uiEvent_t *ev, bool isFirstRun)
{
if (isFirstRun)
{
menuDataGlobal.numItems = totalEntries();
menuDataGlobal.currentItemIndex = 0;
updateScreen();
return (MENU_STATUS_LIST_TYPE | MENU_STATUS_SUCCESS);
}
menuSMSQuickTextExitCode = MENU_STATUS_SUCCESS;
// re-sync after returning from the editor
if (menuDataGlobal.numItems != totalEntries())
{
menuDataGlobal.numItems = totalEntries();
if (menuDataGlobal.currentItemIndex >= menuDataGlobal.numItems)
{
menuDataGlobal.currentItemIndex = (menuDataGlobal.numItems > 0) ? (menuDataGlobal.numItems - 1) : 0;
}
updateScreen();
}
if (ev->hasEvent)
{
handleEvent(ev);
}
return menuSMSQuickTextExitCode;
}
static void updateScreen(void)
{
int mNum;
displayClearBuf();
menuDisplayTitle(currentLanguage->quick_text);
for (int i = MENU_START_ITERATION_VALUE; i <= MENU_END_ITERATION_VALUE; i++)
{
if (menuDataGlobal.numItems == 0)
{
break;
}
mNum = menuGetMenuOffset(menuDataGlobal.numItems, i);
if (mNum == MENU_OFFSET_BEFORE_FIRST_ENTRY)
{
continue;
}
else if (mNum == MENU_OFFSET_AFTER_LAST_ENTRY)
{
break;
}
const char *text = smsQuickTextGet((uint8_t)mNum);
menuDisplayEntry(i, mNum, ((text != NULL) ? text : currentLanguage->new_message), 0,
THEME_ITEM_FG_TEXT_INPUT, THEME_ITEM_COLOUR_NONE, THEME_ITEM_BG);
}
displayRender();
}
static bool deleteQuickTextCallback(void)
{
if (uiDataGlobal.MessageBox.keyPressed == KEY_GREEN)
{
smsQuickTextDelete((uint8_t)menuDataGlobal.currentItemIndex);
}
return true;
}
static void handleEvent(uiEvent_t *ev)
{
if ((ev->events & KEY_EVENT) == 0)
{
return;
}
bool onExistingEntry = (menuDataGlobal.currentItemIndex < smsQuickTextCount());
if (KEYCHECK_SHORTUP(ev->keys, KEY_RED))
{
if (BUTTONCHECK_DOWN(ev, BUTTON_SK2) && onExistingEntry)
{
snprintf(uiDataGlobal.MessageBox.message, MESSAGEBOX_MESSAGE_LEN_MAX, "%s", currentLanguage->delete_message);
uiDataGlobal.MessageBox.type = MESSAGEBOX_TYPE_INFO;
uiDataGlobal.MessageBox.buttons = MESSAGEBOX_BUTTONS_YESNO;
uiDataGlobal.MessageBox.decoration = MESSAGEBOX_DECORATION_FRAME;
uiDataGlobal.MessageBox.validatorCallback = deleteQuickTextCallback;
menuSystemPushNewMenu(UI_MESSAGE_BOX);
}
else
{
menuSystemPopPreviousMenu();
}
return;
}
else if (KEYCHECK_SHORTUP(ev->keys, KEY_GREEN))
{
if (BUTTONCHECK_DOWN(ev, BUTTON_SK2) || (onExistingEntry == false))
{
// edit the slot (or create a new one)
menuSMSComposeSetQuickTextEditTarget(menuDataGlobal.currentItemIndex);
menuSystemPushNewMenu(MENU_SMS_COMPOSE);
}
else
{
// use the template for a new message
menuSMSComposePrefill(0, smsQuickTextGet((uint8_t)menuDataGlobal.currentItemIndex));
menuSystemPushNewMenu(MENU_SMS_COMPOSE);
}
return;
}
else if (KEYCHECK_PRESS(ev->keys, KEY_DOWN))
{
menuSystemMenuIncrement(&menuDataGlobal.currentItemIndex, menuDataGlobal.numItems);
updateScreen();
}
else if (KEYCHECK_PRESS(ev->keys, KEY_UP))
{
menuSystemMenuDecrement(&menuDataGlobal.currentItemIndex, menuDataGlobal.numItems);
updateScreen();
}
}

View file

@ -90,7 +90,15 @@ menuDataGlobal_t menuDataGlobal =
#if !defined(PLATFORM_GD77S)
NULL,// APRS options
#endif
&menuDataSMS,// SMS hub
// *** Add new menus to be accessed using quickkey (ID: 0..31) above this line ***
NULL,// SMS compose
NULL,// SMS inbox
NULL,// SMS sent
NULL,// SMS view
NULL,// SMS quick texts
NULL,// SMS options
NULL,// SMS RX popup
NULL,// MessageBox
NULL,// hotspot mode
NULL,// CPS
@ -148,7 +156,15 @@ static menuFunctionData_t menuFunctions[] =
#if !defined(PLATFORM_GD77S)
{ menuAPRSOptions, NULL, NULL, 0 },
#endif
{ menuDisplayMenuList, NULL, NULL, 0 },// SMS hub
// *** Add new menus to be accessed using quickkey (ID: 0..31) above this line ***
{ menuSMSCompose, NULL, NULL, 0 },
{ menuSMSList, NULL, NULL, 0 },// SMS inbox
{ menuSMSList, NULL, NULL, 0 },// SMS sent
{ menuSMSList, NULL, NULL, 0 },// SMS view
{ menuSMSQuickText, NULL, NULL, 0 },
{ menuSMSOptions, NULL, NULL, 0 },
{ uiSMSPopup, NULL, NULL, 0 },
{ uiMessageBox, NULL, NULL, 0 },
{ menuHotspotMode, NULL, NULL, 0 },
{ uiCPS, NULL, NULL, 0 },
@ -649,6 +665,7 @@ const menuItemNewData_t mainMenuItems[] =
{
{ 3, MENU_ZONE_LIST },
{ 6, MENU_CONTACTS_MENU },
{ 282, MENU_SMS },// messages
{ 12, MENU_CHANNEL_DETAILS },
{ 4, MENU_RSSI_SCREEN },
{ 8, MENU_FIRMWARE_INFO },
@ -667,6 +684,21 @@ const menuItemsList_t menuDataMainMenu =
.items = mainMenuItems
};
static const menuItemNewData_t smsMenuItems[] =
{
{ 283, MENU_SMS_COMPOSE },// new message
{ 284, MENU_SMS_INBOX },// inbox
{ 285, MENU_SMS_SENT },// sent
{ 286, MENU_SMS_QUICKTEXT },// quick text
{ 9, MENU_SMS_OPTIONS },// options
};
const menuItemsList_t menuDataSMS =
{
.numItems = (sizeof(smsMenuItems) / sizeof(smsMenuItems[0])),
.items = smsMenuItems
};
static const menuItemNewData_t contactMenuItems[] =
{
{ 15, MENU_CONTACT_LIST },

View file

@ -32,6 +32,7 @@
#include "user_interface/uiLocalisation.h"
#include "functions/voicePrompts.h"
#include "functions/rxPowerSaving.h"
#include "functions/smsCore.h"
#if defined(PLATFORM_MD9600) || defined(PLATFORM_MD380) || defined(PLATFORM_MDUV380) || defined(PLATFORM_RT84_DM1701) || defined(PLATFORM_MD2017)
#include "hardware/radioHardwareInterface.h"
#endif
@ -274,6 +275,13 @@ menuStatus_t uiChannelMode(uiEvent_t *ev, bool isFirstRun)
{
return menuChannelExitStatus;
}
// A new text message arrived
if (smsHasUnseenRxMessage() && (trxTransmissionEnabled == false) && (trxIsTransmitting == false))
{
menuSystemPushNewMenu(UI_SMS_RX_POPUP);
return menuChannelExitStatus;
}
#endif
if (ev->events == NO_EVENT)
@ -1298,6 +1306,20 @@ static void handleEvent(uiEvent_t *ev)
return;
}
else
#else
if (KEYCHECK_LONGDOWN(ev->keys, KEY_GREEN))
{
// Direct access to the DMR text messages
if (uiDataGlobal.Scan.active)
{
uiChannelModeStopScanning();
}
menuSystemPushNewMenu(MENU_SMS);
menuChannelExitStatus |= (MENU_STATUS_LIST_TYPE | MENU_STATUS_FORCE_FIRST);
return;
}
else
#endif
if (KEYCHECK_SHORTUP(ev->keys, KEY_GREEN))
{

View file

@ -0,0 +1,177 @@
/*
* Copyright (C) 2026-today Marcus Kida, DK1DA
*
*
* Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions
* are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer
* in the documentation and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived
* from this software without specific prior written permission.
*
* 4. Use of this source code or binary releases for commercial purposes is strictly forbidden. This includes, without limitation,
* incorporation in a commercial product or incorporation into a product or project which allows commercial use.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
*/
/*
* Incoming message popup: shown over the channel/VFO screen when a new
* message arrives. GREEN opens it, SK2+GREEN starts a reply, RED dismisses.
*/
#include "user_interface/uiGlobals.h"
#include "user_interface/menuSystem.h"
#include "user_interface/uiLocalisation.h"
#include "user_interface/uiUtilities.h"
#include "functions/smsCore.h"
#include "functions/smsStorage.h"
#define TEXT_CHARS_PER_LINE (DISPLAY_SIZE_X / 8)
#define PREVIEW_LINES 3
static void updateScreen(void);
static void handleEvent(uiEvent_t *ev);
static void smsAlertTone(void);
menuStatus_t uiSMSPopup(uiEvent_t *ev, bool isFirstRun)
{
if (isFirstRun)
{
smsAcknowledgeRxMessage();
if (smsInboxGet(0) == NULL)
{
menuSystemPopPreviousMenu();
return MENU_STATUS_SUCCESS;
}
updateScreen();
smsAlertTone();
return MENU_STATUS_SUCCESS;
}
// a newer message replaces the popup content
if (smsHasUnseenRxMessage())
{
smsAcknowledgeRxMessage();
updateScreen();
smsAlertTone();
}
if (ev->hasEvent)
{
handleEvent(ev);
}
return MENU_STATUS_SUCCESS;
}
static void smsAlertTone(void)
{
if ((voicePromptsIsPlaying() == false) && (melody_play == NULL) && (audioAmpGetStatus() == AUDIO_AMP_CHANNEL_NONE))
{
soundSetMelody(MELODY_SMS_RX);
}
}
static void updateScreen(void)
{
char lineBuffer[TEXT_CHARS_PER_LINE + 1];
char nameBuffer[SCREEN_LINE_BUFFER_SIZE];
const smsRecord_t *record = smsInboxGet(0);
if (record == NULL)
{
return;
}
displayClearBuf();
menuDisplayTitle(currentLanguage->message_from);
if (contactIDLookup(record->peerId, CONTACT_CALLTYPE_PC, nameBuffer) == false)
{
dmrIdDataStruct_t idRecord;
if (dmrIDLookup(record->peerId, &idRecord))
{
strncpy(nameBuffer, idRecord.text, sizeof(nameBuffer) - 1);
nameBuffer[sizeof(nameBuffer) - 1] = 0;
}
else
{
snprintf(nameBuffer, sizeof(nameBuffer), "%u", (unsigned int)record->peerId);
}
}
displayPrintCentered(FONT_SIZE_3_HEIGHT, nameBuffer, FONT_SIZE_3);
int textLength = strlen(record->text);
for (int line = 0; line < PREVIEW_LINES; line++)
{
int offset = line * TEXT_CHARS_PER_LINE;
if (offset >= textLength)
{
break;
}
int chunk = textLength - offset;
if (chunk > TEXT_CHARS_PER_LINE)
{
chunk = TEXT_CHARS_PER_LINE;
}
memcpy(lineBuffer, &record->text[offset], chunk);
lineBuffer[chunk] = 0;
displayPrintAt(0, (FONT_SIZE_3_HEIGHT * 2) + (line * FONT_SIZE_3_HEIGHT), lineBuffer, FONT_SIZE_3);
}
displayRender();
}
static void handleEvent(uiEvent_t *ev)
{
if ((ev->events & KEY_EVENT) == 0)
{
return;
}
if (KEYCHECK_SHORTUP(ev->keys, KEY_RED))
{
menuSystemPopPreviousMenu();
}
else if (KEYCHECK_SHORTUP(ev->keys, KEY_GREEN))
{
menuSystemPopPreviousMenu();
if (BUTTONCHECK_DOWN(ev, BUTTON_SK2))
{
const smsRecord_t *record = smsInboxGet(0);
if (record != NULL)
{
menuSMSComposePrefill(record->peerId, NULL);
menuSystemPushNewMenu(MENU_SMS_COMPOSE);
}
}
else
{
smsInboxMarkRead(0);
menuSMSListSetViewTarget(false, 0);
menuSystemPushNewMenu(MENU_SMS_VIEW);
}
}
}

View file

@ -32,6 +32,7 @@
#endif
#include "functions/trx.h"
#include "functions/rxPowerSaving.h"
#include "functions/smsCore.h"
#include "user_interface/menuSystem.h"
#include "user_interface/uiUtilities.h"
#include "user_interface/uiLocalisation.h"
@ -258,6 +259,13 @@ menuStatus_t uiVFOMode(uiEvent_t *ev, bool isFirstRun)
{
return menuVFOExitStatus;
}
// A new text message arrived
if (smsHasUnseenRxMessage() && (trxTransmissionEnabled == false) && (trxIsTransmitting == false))
{
menuSystemPushNewMenu(UI_SMS_RX_POPUP);
return menuVFOExitStatus;
}
#endif
if (ev->events == NO_EVENT)
@ -1311,6 +1319,20 @@ static void handleEvent(uiEvent_t *ev)
return;
}
else
#else
if (KEYCHECK_LONGDOWN(ev->keys, KEY_GREEN))
{
// Direct access to the DMR text messages
if (uiDataGlobal.Scan.active)
{
uiVFOModeStopScanning();
}
menuSystemPushNewMenu(MENU_SMS);
menuVFOExitStatus |= (MENU_STATUS_LIST_TYPE | MENU_STATUS_FORCE_FIRST);
return;
}
else
#endif
if (KEYCHECK_LONGDOWN(ev->keys, KEY_HASH) && (KEYCHECK_LONGDOWN_REPEAT(ev->keys, KEY_HASH) == false))
{