diff --git a/CHANGELOG.md b/CHANGELOG.md index ada378d..ef3b7b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/MANUAL.md b/MANUAL.md index 0e20db8..f8e510a 100644 --- a/MANUAL.md +++ b/MANUAL.md @@ -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] diff --git a/MDUV380_firmware/application/include/functions/dmrDataProtocol.h b/MDUV380_firmware/application/include/functions/dmrDataProtocol.h new file mode 100644 index 0000000..7bcc0d1 --- /dev/null +++ b/MDUV380_firmware/application/include/functions/dmrDataProtocol.h @@ -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 +#include + +#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_ */ diff --git a/MDUV380_firmware/application/include/functions/smsCore.h b/MDUV380_firmware/application/include/functions/smsCore.h new file mode 100644 index 0000000..6ee232d --- /dev/null +++ b/MDUV380_firmware/application/include/functions/smsCore.h @@ -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 +#include +#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_ */ diff --git a/MDUV380_firmware/application/include/functions/smsStorage.h b/MDUV380_firmware/application/include/functions/smsStorage.h new file mode 100644 index 0000000..b201e95 --- /dev/null +++ b/MDUV380_firmware/application/include/functions/smsStorage.h @@ -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 +#include +#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_ */ diff --git a/MDUV380_firmware/application/include/functions/sound.h b/MDUV380_firmware/application/include/functions/sound.h index 74ffc72..0d585f2 100644 --- a/MDUV380_firmware/application/include/functions/sound.h +++ b/MDUV380_firmware/application/include/functions/sound.h @@ -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[]; diff --git a/MDUV380_firmware/application/include/hardware/HR-C6000.h b/MDUV380_firmware/application/include/hardware/HR-C6000.h index 5e5f77b..1fe76bc 100644 --- a/MDUV380_firmware/application/include/hardware/HR-C6000.h +++ b/MDUV380_firmware/application/include/hardware/HR-C6000.h @@ -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); diff --git a/MDUV380_firmware/application/include/user_interface/languages/catalan.h b/MDUV380_firmware/application/include/user_interface/languages/catalan.h index df004fd..7969042 100644 --- a/MDUV380_firmware/application/include/user_interface/languages/catalan.h +++ b/MDUV380_firmware/application/include/user_interface/languages/catalan.h @@ -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 }; /******************************************************************** * diff --git a/MDUV380_firmware/application/include/user_interface/languages/croatian.h b/MDUV380_firmware/application/include/user_interface/languages/croatian.h index be8682d..6dc1aad 100644 --- a/MDUV380_firmware/application/include/user_interface/languages/croatian.h +++ b/MDUV380_firmware/application/include/user_interface/languages/croatian.h @@ -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 }; /******************************************************************** * diff --git a/MDUV380_firmware/application/include/user_interface/languages/czech.h b/MDUV380_firmware/application/include/user_interface/languages/czech.h index 4b6aaa7..36cecbd 100644 --- a/MDUV380_firmware/application/include/user_interface/languages/czech.h +++ b/MDUV380_firmware/application/include/user_interface/languages/czech.h @@ -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 }; /******************************************************************** * diff --git a/MDUV380_firmware/application/include/user_interface/languages/danish.h b/MDUV380_firmware/application/include/user_interface/languages/danish.h index beb55e5..9f8098b 100644 --- a/MDUV380_firmware/application/include/user_interface/languages/danish.h +++ b/MDUV380_firmware/application/include/user_interface/languages/danish.h @@ -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 }; /******************************************************************** * diff --git a/MDUV380_firmware/application/include/user_interface/languages/dutch.h b/MDUV380_firmware/application/include/user_interface/languages/dutch.h index b15fbaf..6544027 100644 --- a/MDUV380_firmware/application/include/user_interface/languages/dutch.h +++ b/MDUV380_firmware/application/include/user_interface/languages/dutch.h @@ -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 }; /******************************************************************** * diff --git a/MDUV380_firmware/application/include/user_interface/languages/english.h b/MDUV380_firmware/application/include/user_interface/languages/english.h index 3a46761..0e1f129 100644 --- a/MDUV380_firmware/application/include/user_interface/languages/english.h +++ b/MDUV380_firmware/application/include/user_interface/languages/english.h @@ -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 }; /******************************************************************** * diff --git a/MDUV380_firmware/application/include/user_interface/languages/finnish.h b/MDUV380_firmware/application/include/user_interface/languages/finnish.h index 04b992a..fd2ccbe 100644 --- a/MDUV380_firmware/application/include/user_interface/languages/finnish.h +++ b/MDUV380_firmware/application/include/user_interface/languages/finnish.h @@ -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 }; /******************************************************************** * diff --git a/MDUV380_firmware/application/include/user_interface/languages/french.h b/MDUV380_firmware/application/include/user_interface/languages/french.h index 2d5b5cc..7a50eac 100644 --- a/MDUV380_firmware/application/include/user_interface/languages/french.h +++ b/MDUV380_firmware/application/include/user_interface/languages/french.h @@ -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 d’ACK", // 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 }; /******************************************************************** * diff --git a/MDUV380_firmware/application/include/user_interface/languages/german.h b/MDUV380_firmware/application/include/user_interface/languages/german.h index 909968c..c03e8e3 100644 --- a/MDUV380_firmware/application/include/user_interface/languages/german.h +++ b/MDUV380_firmware/application/include/user_interface/languages/german.h @@ -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 }; /******************************************************************** * diff --git a/MDUV380_firmware/application/include/user_interface/languages/hungarian.h b/MDUV380_firmware/application/include/user_interface/languages/hungarian.h index d76e720..6cc187d 100644 --- a/MDUV380_firmware/application/include/user_interface/languages/hungarian.h +++ b/MDUV380_firmware/application/include/user_interface/languages/hungarian.h @@ -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 }; /******************************************************************** * diff --git a/MDUV380_firmware/application/include/user_interface/languages/italian.h b/MDUV380_firmware/application/include/user_interface/languages/italian.h index c3e1542..252d196 100644 --- a/MDUV380_firmware/application/include/user_interface/languages/italian.h +++ b/MDUV380_firmware/application/include/user_interface/languages/italian.h @@ -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 }; /******************************************************************** * diff --git a/MDUV380_firmware/application/include/user_interface/languages/japanese.h b/MDUV380_firmware/application/include/user_interface/languages/japanese.h index 7866290..e114451 100644 --- a/MDUV380_firmware/application/include/user_interface/languages/japanese.h +++ b/MDUV380_firmware/application/include/user_interface/languages/japanese.h @@ -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 }; /******************************************************************** * diff --git a/MDUV380_firmware/application/include/user_interface/languages/polish.h b/MDUV380_firmware/application/include/user_interface/languages/polish.h index 924b5d6..063e244 100644 --- a/MDUV380_firmware/application/include/user_interface/languages/polish.h +++ b/MDUV380_firmware/application/include/user_interface/languages/polish.h @@ -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 }; /******************************************************************** * diff --git a/MDUV380_firmware/application/include/user_interface/languages/portugues_brazil.h b/MDUV380_firmware/application/include/user_interface/languages/portugues_brazil.h index 8951c9c..a417627 100644 --- a/MDUV380_firmware/application/include/user_interface/languages/portugues_brazil.h +++ b/MDUV380_firmware/application/include/user_interface/languages/portugues_brazil.h @@ -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 }; /********************************************************************* * diff --git a/MDUV380_firmware/application/include/user_interface/languages/portuguese.h b/MDUV380_firmware/application/include/user_interface/languages/portuguese.h index f53c8ef..055b571 100644 --- a/MDUV380_firmware/application/include/user_interface/languages/portuguese.h +++ b/MDUV380_firmware/application/include/user_interface/languages/portuguese.h @@ -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 }; /******************************************************************** * diff --git a/MDUV380_firmware/application/include/user_interface/languages/romanian.h b/MDUV380_firmware/application/include/user_interface/languages/romanian.h index 874c6ad..86cae76 100644 --- a/MDUV380_firmware/application/include/user_interface/languages/romanian.h +++ b/MDUV380_firmware/application/include/user_interface/languages/romanian.h @@ -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 }; /******************************************************************** * diff --git a/MDUV380_firmware/application/include/user_interface/languages/slovenian.h b/MDUV380_firmware/application/include/user_interface/languages/slovenian.h index 7677e54..b86bf4b 100644 --- a/MDUV380_firmware/application/include/user_interface/languages/slovenian.h +++ b/MDUV380_firmware/application/include/user_interface/languages/slovenian.h @@ -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 }; /******************************************************************** * diff --git a/MDUV380_firmware/application/include/user_interface/languages/spanish.h b/MDUV380_firmware/application/include/user_interface/languages/spanish.h index a8e5b96..347418c 100644 --- a/MDUV380_firmware/application/include/user_interface/languages/spanish.h +++ b/MDUV380_firmware/application/include/user_interface/languages/spanish.h @@ -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 }; /******************************************************************** * diff --git a/MDUV380_firmware/application/include/user_interface/languages/swedish.h b/MDUV380_firmware/application/include/user_interface/languages/swedish.h index 85e54f7..0e3a0ff 100644 --- a/MDUV380_firmware/application/include/user_interface/languages/swedish.h +++ b/MDUV380_firmware/application/include/user_interface/languages/swedish.h @@ -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 }; /******************************************************************** * diff --git a/MDUV380_firmware/application/include/user_interface/languages/turkish.h b/MDUV380_firmware/application/include/user_interface/languages/turkish.h index abf11b6..dc7148d 100644 --- a/MDUV380_firmware/application/include/user_interface/languages/turkish.h +++ b/MDUV380_firmware/application/include/user_interface/languages/turkish.h @@ -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 }; /******************************************************************** * diff --git a/MDUV380_firmware/application/include/user_interface/languages/uiLanguage.h b/MDUV380_firmware/application/include/user_interface/languages/uiLanguage.h index ac49dca..c423e6e 100644 --- a/MDUV380_firmware/application/include/user_interface/languages/uiLanguage.h +++ b/MDUV380_firmware/application/include/user_interface/languages/uiLanguage.h @@ -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_ diff --git a/MDUV380_firmware/application/include/user_interface/menuSystem.h b/MDUV380_firmware/application/include/user_interface/menuSystem.h index 4798637..aad576e 100644 --- a/MDUV380_firmware/application/include/user_interface/menuSystem.h +++ b/MDUV380_firmware/application/include/user_interface/menuSystem.h @@ -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 diff --git a/MDUV380_firmware/application/source/applicationMain.c b/MDUV380_firmware/application/source/applicationMain.c index ba42160..cc39c1f 100644 --- a/MDUV380_firmware/application/source/applicationMain.c +++ b/MDUV380_firmware/application/source/applicationMain.c @@ -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)) diff --git a/MDUV380_firmware/application/source/functions/dmrDataProtocol.c b/MDUV380_firmware/application/source/functions/dmrDataProtocol.c new file mode 100644 index 0000000..34e920f --- /dev/null +++ b/MDUV380_firmware/application/source/functions/dmrDataProtocol.c @@ -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 +#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; +} diff --git a/MDUV380_firmware/application/source/functions/smsCore.c b/MDUV380_firmware/application/source/functions/smsCore.c new file mode 100644 index 0000000..8e2878e --- /dev/null +++ b/MDUV380_firmware/application/source/functions/smsCore.c @@ -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 +#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; + } +} diff --git a/MDUV380_firmware/application/source/functions/smsStorage.c b/MDUV380_firmware/application/source/functions/smsStorage.c new file mode 100644 index 0000000..1e256f2 --- /dev/null +++ b/MDUV380_firmware/application/source/functions/smsStorage.c @@ -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 +#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(); + } +} diff --git a/MDUV380_firmware/application/source/functions/sound.c b/MDUV380_firmware/application/source/functions/sound.c index 7ad7356..8c3fe4a 100644 --- a/MDUV380_firmware/application/source/functions/sound.c +++ b/MDUV380_firmware/application/source/functions/sound.c @@ -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 diff --git a/MDUV380_firmware/application/source/hardware/HR-C6000.c b/MDUV380_firmware/application/source/hardware/HR-C6000.c index 5111c68..d7249c5 100644 --- a/MDUV380_firmware/application/source/hardware/HR-C6000.c +++ b/MDUV380_firmware/application/source/hardware/HR-C6000.c @@ -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 - hrc6000SendPcOrTgLCHeader(); - SPI0WritePageRegByte(0x04, 0x41, 0x80); // Transmit during next Timeslot - SPI0WritePageRegByte(0x04, 0x50, 0x10); // Set Data Type to 0001 (Voice LC Header), Data, LCSS=00 + 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) - SPI0WritePageRegByte(0x04, 0x41, 0x80); // Transmit during Next Timeslot - SPI0WritePageRegByte(0x04, 0x50, 0x10); // Set Data Type to 0001 (Voice LC Header), Data, LCSS=00 + 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) - SPI0WritePageRegByte(0x04, 0x41, 0x80); // Transmit during next Timeslot - SPI0WritePageRegByte(0x04, 0x50, 0x10); // Set Data Type to 0001 (Voice LC Header), Data, LCSS=00 + 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); diff --git a/MDUV380_firmware/application/source/user_interface/menuSMSCompose.c b/MDUV380_firmware/application/source/user_interface/menuSMSCompose.c new file mode 100644 index 0000000..bc0e987 --- /dev/null +++ b/MDUV380_firmware/application/source/user_interface/menuSMSCompose.c @@ -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 +#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(); + } + } + } + } +} diff --git a/MDUV380_firmware/application/source/user_interface/menuSMSList.c b/MDUV380_firmware/application/source/user_interface/menuSMSList.c new file mode 100644 index 0000000..649eddf --- /dev/null +++ b/MDUV380_firmware/application/source/user_interface/menuSMSList.c @@ -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(); + } +} diff --git a/MDUV380_firmware/application/source/user_interface/menuSMSOptions.c b/MDUV380_firmware/application/source/user_interface/menuSMSOptions.c new file mode 100644 index 0000000..f01ec11 --- /dev/null +++ b/MDUV380_firmware/application/source/user_interface/menuSMSOptions.c @@ -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(); + } +} diff --git a/MDUV380_firmware/application/source/user_interface/menuSMSQuickText.c b/MDUV380_firmware/application/source/user_interface/menuSMSQuickText.c new file mode 100644 index 0000000..ad577d1 --- /dev/null +++ b/MDUV380_firmware/application/source/user_interface/menuSMSQuickText.c @@ -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(); + } +} diff --git a/MDUV380_firmware/application/source/user_interface/menuSystem.c b/MDUV380_firmware/application/source/user_interface/menuSystem.c index e249823..44044d9 100644 --- a/MDUV380_firmware/application/source/user_interface/menuSystem.c +++ b/MDUV380_firmware/application/source/user_interface/menuSystem.c @@ -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 }, diff --git a/MDUV380_firmware/application/source/user_interface/uiChannelMode.c b/MDUV380_firmware/application/source/user_interface/uiChannelMode.c index ffd8d1c..f4a09c8 100644 --- a/MDUV380_firmware/application/source/user_interface/uiChannelMode.c +++ b/MDUV380_firmware/application/source/user_interface/uiChannelMode.c @@ -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)) { diff --git a/MDUV380_firmware/application/source/user_interface/uiSMSPopup.c b/MDUV380_firmware/application/source/user_interface/uiSMSPopup.c new file mode 100644 index 0000000..b52c9ea --- /dev/null +++ b/MDUV380_firmware/application/source/user_interface/uiSMSPopup.c @@ -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); + } + } +} diff --git a/MDUV380_firmware/application/source/user_interface/uiVFOMode.c b/MDUV380_firmware/application/source/user_interface/uiVFOMode.c index 32c0c11..8beb961 100644 --- a/MDUV380_firmware/application/source/user_interface/uiVFOMode.c +++ b/MDUV380_firmware/application/source/user_interface/uiVFOMode.c @@ -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)) {