Compare commits

...

39 commits
v0.2.0 ... main

Author SHA1 Message Date
Marcus Kida
fa2ad5d0d2 Docs: document zone-based scanning; make version example non-drifting
The Scanning section only said 'per upstream OpenGD77', which left the
scan-list question unanswered. Explain that zones are the scan list, the
Zone Skip / All Skip flags, the start key and the Radio options settings.
Also replace the version example that goes stale every release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 17:37:14 +02:00
Marcus Kida
51a1c04b9b README: refresh screenshots and show messaging + menu screens
Replace the VFO/DMR screenshots with current captures and add the main
menu, the Messages menu and a received message. All shot with the
Neon 80s theme.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 17:34:38 +02:00
Marcus Kida
66aac3587f Release 0.11.0
CRT-style shut-off animation that collapses the current screen when powering
off (colour displays), replacing the 'Power Off' message.

Bump version to 0.11.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 23:51:39 +02:00
Marcus Kida
bc696ae5b2 Power off: CRT-style collapse animation
Replace the 'Power Off' / 73 message on colour displays with a shut-off
animation that collapses the current screen towards the middle (vertical
squeeze to a line, then horizontal collapse to a centre dot) like an old CRT.
Snapshots the live framebuffer into the (idle) notification offscreen buffer
so no extra RAM is needed; runs blocking with vTaskDelay between frames.
Mono displays keep the existing message. Docs updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 23:51:24 +02:00
Marcus Kida
91043a006a Release 0.10.1
Remove the muted 'M' status-bar icon that overlapped the GPS text; the
flashing red LED indicates mute.

Bump version to 0.10.1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 23:28:09 +02:00
Marcus Kida
c770693848 Fix: remove muted 'M' header icon that overlapped GPS text
With GPS enabled, the bold 'M' mute indicator in the status bar collided
with the GPS text. Drop the header icon; the flashing red LED already
indicates the muted state. Docs updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 23:27:43 +02:00
Marcus Kida
d774604d74 Docs: document contact picker, combined Contacts list, build memory summary
- README: SMS bullet mentions picking a recipient from contacts.
- MANUAL: contact picker in the compose flow, a Contacts-list section (all
  types + '#' to cycle the filter), and a current version example.
- TOOLS: note the per-region memory-usage summary printed after each build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 23:12:42 +02:00
Marcus Kida
6a5731e8a4 Release 0.10.0
SMS recipient picker (GREEN in the compose recipient field selects from your
private-call DMR contacts) and a combined 'Contacts' view in Menu -> Contacts.

Bump version to 0.10.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 23:08:59 +02:00
Marcus Kida
15bbc1bc3a SMS: pick recipient from contacts; contacts list shows all types
- New SMS recipient picker: GREEN in the compose recipient field opens a list
  of private-call DMR contacts; selecting one fills the destination (using the
  contact's plain DMR ID) and moves to the message text. Manual ID entry still
  works (DOWN advances to the text field).
- Menu -> Contacts now defaults to a combined 'Contacts' view of all contact
  types instead of the group-only filter; HASH still cycles the per-type
  filters. Per-type code paths are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 23:08:25 +02:00
Marcus Kida
a94fd59a10 Release 0.9.0
Pride theme: status/header bar and menu title bar now use the top rainbow
stripe colour instead of white.

Bump version to 0.9.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 17:52:45 +02:00
Marcus Kida
959f7f2a03 Pride theme: paint status and menu title bars in the top stripe colour
Blend the header/status bar and the menu title bar into the rainbow by
filling them with the top pride stripe colour instead of white.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 15:47:26 +02:00
Marcus Kida
3fe33b931d Release 0.8.0
New colour theme presets (Red, Neon 80s, and the rainbow-background Pride),
plus a per-region memory-usage summary printed after every build.

Bump version to 0.8.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 14:48:17 +02:00
Marcus Kida
3ec4eafbc9 Themes: add 'Pride' rainbow-background preset
The theme system stores one flat background colour per element, so a rainbow
background can't be expressed as a preset alone. Add a small display hook:
when Pride is selected, displayClearBuf()/displayClearRows() paint the six
pride-flag stripes across the framebuffer instead of a flat fill (gated on a
flag only the Pride preset sets, and behind HAS_COLOURS so mono builds are
untouched). Non-selected menu rows draw text straight over the canvas, while
the title/header/notifications paint solid white boxes, so chrome stays
readable over the stripes; blue/violet are lightened slightly for black-text
contrast. Docs and changelog updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 14:47:45 +02:00
Marcus Kida
5efd14d8b7 Docs: reflect removed menu icons and new themes/SMS details
- README/MANUAL: drop 'menu icons' (reverted to plain-text entries),
  add the Red and Neon 80s theme presets, and surface DMR text messaging
  in the README feature list.
- MANUAL: correct the quick-text count (10 -> 8).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 14:37:01 +02:00
Marcus Kida
975661689a Build: report per-region memory usage after each build
Pass --print-memory-usage to the linker and re-print its FLASH/RAM/CCMRAM
table in the build-script summary with a computed Free column, so the
headroom for further features is visible at the end of every build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 10:49:22 +02:00
Marcus Kida
c614ba8096 Themes: add 'Red' and multi-colour 'Neon 80s' presets
Red mirrors the two-tone Terminal/Vertexic presets (black-on-red day,
red-on-black night). Neon 80s is a synthwave palette that assigns distinct
neon colours (cyan, magenta, violet, green, yellow, red, orange) to the
individual theme items over a dark background, demonstrating that presets
are not limited to two colours.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 10:40:37 +02:00
Marcus Kida
5646d2df11 Release 0.7.1
Fix a crash when navigating the Messages menu with voice prompts enabled:
menu strings added after the voice-prompt pack was built read past the end
of the table of contents and now play silence instead.

Bump version to 0.7.1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:32:26 +02:00
Marcus Kida
9eceb10013 Fix crash announcing menu strings past the voice-prompt table
voicePromptsAppendLanguageString() turns a language-string pointer into a
prompt index (NUM_VOICE_PROMPTS + offset), and voicePromptsPlay() then reads
tableOfContents[index + 1]. Strings added after the voice-prompt pack was
built (the SMS menu entries) land past the 368-entry table, so the player
read off the end of the array and the radio crashed when the Messages menu
announced a submenu name with voice prompts enabled.

Clamp any index at or beyond the last table slot to PROMPT_SILENCE, so such
strings are simply not spoken. Also protects the pre-existing strings sitting
at the very end of the table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:31:09 +02:00
Marcus Kida
d07d3c4b9c Add second DMR screenshot to docs images
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:08:06 +02:00
Marcus Kida
741578e294 Release 0.7.0
DMR text messages (SMS): compose, inbox/sent, quick texts, delivery
confirmation and an incoming-message popup, compatible with
BrandMeister/hotspots (Motorola TMS) and Anytone/"DMR standard"
senders. Also removes the main-menu icons (plain-text entries again).

Bump version to 0.7.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:53:33 +02:00
Marcus Kida
31c31df861 DMR text messages (SMS): ETSI/Motorola-TMS messaging on the radio
Full messaging support, implemented fresh in three layers:

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:52:54 +02:00
Marcus Kida
58e8269500 Add missing LedGreenSuppress() declaration to LEDs.h
The implementation and callers landed with the 0.6.0 green-LED mute fix,
but the header declaration was left uncommitted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:52:19 +02:00
Marcus Kida
8dcda476aa Revert main-menu icons: plain-text entries again
Remove the 16x16 main-menu icon bitmaps, menuIconForMenuNumber(),
menuSetNextEntryIconByMenuNum() and the icon drawing + text offset in
menuDisplayEntry(). Top-level menu entries render as plain text as before.
Docs updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 12:04:56 +02:00
Marcus Kida
ff1559ec5e Release 0.6.0
On-radio theme presets (Custom / BASIC 80s / Terminal / Vertexic), the green-LED
flicker fix while muted during a DMR QSO, and the screenshot.sh helper.

Bump version to 0.6.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 10:04:01 +02:00
Marcus Kida
23fd4df110 Themes: add 'Vertexic' orange preset
New theme preset: orange (#FF6A00) background with black text by day, inverted
(orange on black) at night. Vertex-style amber/orange look. Documented in
MANUAL.md and the README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 10:02:16 +02:00
Marcus Kida
8c9828e386 Themes: selectable on-device presets (Custom / BASIC 80s / Terminal)
Add a Theme selector in Display Options that cycles three presets with a live
preview: Custom (CPS-editable, default), BASIC 80s (Commodore 64 light-blue on
blue) and Terminal (green-on-black night / black-on-green day). Presets fill all
theme items for a cohesive look.

The selection is stored in the codeplug custom-data area (new THEME_PRESET
block), which is migration-safe (missing block -> Custom) and needs no settings
reset. CPS theme edits only ever change the Custom theme. Documented in MANUAL.md
and the README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:41:08 +02:00
Marcus Kida
5ac77c5ae9 Fix: green LED flicker between red mute flashes during a DMR QSO
The DMR RX handler re-asserts the green LED whenever it reads it off, racing the
main-loop mute clear, so green flickered between the red flashes. Add a
LedGreenSuppress() hold in the LED driver: while muted the green LED is forced
off (the re-assert becomes a no-op); on un-mute the hold is released and green is
restored from the current RX state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:15:23 +02:00
Marcus Kida
63a833269a docs(site): mention ./screenshot.sh on the home page
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:09:38 +02:00
Marcus Kida
682a729e5c tools: add screenshot.sh screen-grabber wrapper
One-command screenshot helper around gd-77_screen_grabber.py: auto-detects the
radio's serial port (cu.usbmodem* on macOS, ttyACM* on Linux) and creates a
self-contained Python venv with pyserial + pillow on first run. Saves to
screenshots/ (gitignored, along with the venv). Documented in TOOLS.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:08:12 +02:00
Marcus Kida
e20eabd203 Release 0.5.0
Fix the green RX LED not returning after un-muting mid-QSO, plus the MANUAL.md
user guide and the MkDocs documentation website from this cycle.

Bump version to 0.5.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 13:54:15 +02:00
Marcus Kida
8fcfb66893 Fix: restore green RX LED after un-muting during a QSO
The mute flash forced the green LED off; on un-mute it was blanked instead of
restored, so a call in progress stayed dark. Restore green from the current RX
state (active DMR slot, or FM/digital signal received) on un-mute; the normal RX
code keeps it correct afterwards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 13:46:51 +02:00
Marcus Kida
35ba6e9975 docs: add MkDocs Material documentation website
Renders the root markdown (manual, tools, changelog) into a static, searchable
HTML site with client-side (lunr.js) search and a dark/light theme. Built in
Docker via website/build-docs.sh (no host installs). Public URL is set at build
time with SITE_URL=... (default is a neutral placeholder; the site works at any
URL since site_url only affects the sitemap/canonical links). Generated output
(website/_build/) is gitignored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 13:29:29 +02:00
Marcus Kida
5ae4fa70bd docs: add MANUAL.md user guide (OpenGD77 vs FreeTRX features tagged)
New user-facing feature & usage guide covering inherited OpenGD77 features and
FreeTRX additions, each marked [FreeTRX] or [OpenGD77]. Documents the spectrum
scope (VFO sweep, hold #), channel add/delete, mute, screen dimming, the S-meter
and 7-seg font, plus a button reference. Linked from the README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 13:01:01 +02:00
Marcus Kida
d5ddc4748b Release 0.4.0
On-radio channel add/delete from the Channel quick menu (with translations and a
Managing-channels manual section), flash.sh always rebuilds language packs, and
releases before v1.0.0 are flagged as pre-releases.

Bump version to 0.4.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 12:49:45 +02:00
Marcus Kida
73d40374e7 README: document on-radio channel add/delete; fix stale dim/7-seg notes
Add a 'Managing channels on the radio' section and a feature bullet for channel
create/delete. Update the screen-dim levels (now includes 15%) and drop the
'faintly-lit inactive segments' note (inactive 7-seg segments are now off).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 12:48:55 +02:00
Marcus Kida
a8c2038552 Channels: translate the add/delete menu strings; flash.sh rebuilds packs
Add delete_channel and keep_one_channel to the language table (after mute) and
fill all 20 language files (13 translated, English fallback for the rest), with
correct single-byte ISO-8859 accents. Wire them into the Delete Channel label,
confirmation and last-channel guard. The new strings land on empty voice-table
slots, so they play as silence (no crash, no .vpr regeneration).

flash.sh now always rebuilds the .gla language packs so edits to the language
files are actually picked up (previously a stale pack was re-flashed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 12:47:27 +02:00
Marcus Kida
e145a1188d Channels: on-radio add/delete from the Channel quick menu
Add two codeplug primitives - codeplugAllChannelsIndexSetUnused() (frees a
channel slot: clears the in-use bitmap bit, persists the header, fixes counts)
and codeplugZoneRemoveChannelFromZoneAndSave() (removes a channel from a zone's
list) - and wire two new Channel quick-menu items:

- 'new channel': allocates the first free slot, writes a blank 145.500 FM
  channel, adds it to the current zone, reloads the zone and opens Channel
  Details to edit it.
- 'Delete Channel': confirmation, then scrubs the channel from every real zone
  before freeing the slot. Guards against deleting the last channel and honours
  the read-only/APRS lock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 11:11:48 +02:00
Marcus Kida
7c10a92475 Release script: mark pre-1.0.0 releases as pre-release
Flag any release with major version 0 as a Forgejo pre-release on create, and
PATCH the flag on re-runs so it stays correct. Existing v0.1.0/v0.2.0/v0.3.0
releases were updated to pre-release too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 16:34:06 +02:00
Marcus Kida
dd1cc0ccdf Release 0.3.0: fix Screen Dim label voice-prompt crash
The menu label 'Screen Dim' is a raw literal, and the shared menu code voices
labels through voicePromptsAppendLanguageString(), which derives the prompt
index from the pointer's offset within the language table and crashed on a
foreign pointer (v0.2.0 fixed only the value, not the label). That helper now
ignores pointers outside the language table.

Bump version to 0.3.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 16:29:21 +02:00
75 changed files with 5602 additions and 145 deletions

7
.gitignore vendored
View file

@ -5,3 +5,10 @@ codec_bin_section_1.bin
# Generated language plugin files + host-side builder binary
*.gla
languages_builder
# Generated docs site
website/_build/
# Screenshot tool venv + scratch output
MDUV380_firmware/tools/.venv/
screenshots/

View file

@ -9,6 +9,128 @@ single running list until the first versioned release.
_Nothing yet._
## 0.11.0
- **CRT-style shut-off animation** — when powering off, the current screen
collapses towards the middle (squeezed to a line, then a dot) like an old CRT
switching off, replacing the "Power Off" message. (Colour displays only.)
## 0.10.1
- **Fix:** the muted **"M"** indicator in the status bar overlapped the GPS text
when GPS was enabled. The header icon is removed; the muted state is shown by
the flashing red LED alone.
## 0.10.0
- **SMS recipient from contacts** — when composing a message, press **GREEN** in
the recipient field to pick the destination from your private-call DMR
contacts (or type a DMR ID manually and press **DOWN** to move to the text).
- **Contacts list shows everything** — Menu → Contacts now lists all contact
types together under the title "Contacts" instead of defaulting to the group
filter; **HASH** still cycles the per-type filters (all / groups / private /
all-call).
## 0.9.0
- **Pride theme** — the status/header bar and the menu title bar are now painted
in the top rainbow-stripe colour so they blend into the background instead of
showing as white boxes.
## 0.8.0
- **Three new theme presets** (Display Options → "Theme"): **Red** (black-on-red
day / red-on-black night); **Neon 80s**, an 80s-synthwave palette that maps
several neon colours (cyan, magenta, violet, green, yellow) across the
individual UI elements over a dark background; and **Pride**, which paints the
background as a rainbow pride flag with black text and white menu/header boxes
kept readable over the stripes.
- **Build:** each build now prints a per-region memory-usage summary
(FLASH / RAM / CCMRAM used, size and free) so the remaining headroom is
visible after every build.
## 0.7.1
- **Fix:** the radio **crashed** when navigating the Messages menu with voice
prompts enabled. Menu strings added after the voice-prompt pack was built map
to a table-of-contents slot that doesn't exist; the player read past the end
of the table. Such strings now play silence instead (also hardens the
pre-existing strings sitting at the end of the table).
## 0.7.0
- **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
- **Theme presets** — selectable on the radio in Display Options → "Theme":
**Custom** (CPS-editable, default), **BASIC 80s** (Commodore 64 blue/light-blue),
**Terminal** (green-on-black night / black-on-green day), and **Vertexic**
(orange, black-on-orange day / orange-on-black night). The choice is stored
in the codeplug custom-data area (migration-safe; defaults to Custom), and CPS
theme edits only ever change Custom.
- **Fix:** green LED **flickering** between the red flashes while muted during a DMR
QSO. The DMR RX handler re-asserts the green LED as soon as it reads it off, racing
the main-loop mute clear; the green LED is now **held off at the driver** for the
whole mute (and released on un-mute), so only the red flash shows.
- **`screenshot.sh`** — a one-command wrapper around the OpenGD77 screen grabber
that auto-detects the serial port and sets up a self-contained Python venv
(pyserial + pillow) on first run. Saves to `screenshots/`.
## 0.5.0
- **Fix:** un-muting during an active QSO left the LED **off** instead of returning
to **green**. On un-mute the green RX LED is now restored from the current receive
state (active DMR slot or FM/digital signal), so an in-progress call lights green
again.
- **`MANUAL.md`** — a user-facing feature & usage guide covering both inherited
OpenGD77 features and FreeTRX additions, each tagged 🟢 [FreeTRX] or ⚪
[OpenGD77]. Includes the spectrum scope (VFO sweep, hold `#`), channel add/delete,
mute, dimming, and a button reference.
- **Documentation website** (`website/`) — MkDocs Material config that renders the
markdown into a static, searchable HTML site (client-side search, dark/light
theme). Built in Docker via `./website/build-docs.sh`; the public URL is set with
`SITE_URL=…`. For self-hosting on Forgejo.
## 0.4.0
- **On-radio channel add/delete.** The Channel quick menu (top button) has two new
entries:
- **new channel** — creates a blank 145.500 MHz FM channel in the first free
slot, adds it to the current zone, and opens Channel Details to edit it. The
zone is reloaded so the channel is immediately navigable.
- **Delete Channel** — deletes the selected channel after a confirmation, and
scrubs it out of every zone that referenced it. Refuses to delete the last
remaining channel, and respects the read-only/APRS channel lock.
- The new menu strings are **translated** (German, French, Italian, Spanish,
Portuguese, Dutch, Danish, Swedish, Catalan, Finnish, Czech; English fallback
for the rest).
- **`flash.sh`** now always rebuilds the language (`.gla`) packs, so edits to the
language files are picked up instead of a stale pack being re-flashed.
- **Release script** now flags every release before v1.0.0 as a **pre-release**
(and corrects the flag on re-runs).
## 0.3.0
- **Fix a second voice-prompt crash** on the Screen Dim menu row: the item *label*
("Screen Dim") is a raw literal, and the shared menu code voices labels via
`voicePromptsAppendLanguageString()`, which crashes on any pointer outside the
language table. That function now ignores non-language pointers, so a stray
literal is skipped instead of hard-faulting. (The dim value is still announced.)
## 0.2.0
- **Fix crash** opening Display Options with **voice announcements on**: the Screen

234
MANUAL.md Normal file
View file

@ -0,0 +1,234 @@
# FreeTRX — User Manual
Feature and usage guide for **FreeTRX** (a fork of [OpenGD77](https://opengd77.org))
on the **TYT MD-UV380 / Retevis RT-3S** and **Baofeng DM-1701 / Retevis RT-84**.
## How to read this manual
Each feature is tagged so you can tell what is unique to FreeTRX and what is
inherited from upstream OpenGD77:
- 🟢 **[FreeTRX]** — new or noticeably changed in FreeTRX.
- ⚪ **[OpenGD77]** — inherited from upstream OpenGD77, documented here for
convenience. For exhaustive coverage of upstream behaviour, see the official
OpenGD77 user guide.
> Most day-to-day operation (VFO/Channel, FM, DMR, scanning, hotspot, CPS sync) is
> standard OpenGD77. This manual focuses on **how to reach each feature** and marks
> clearly what FreeTRX adds or changes.
---
## Buttons at a glance
The MD-UV380 / RT-3S has **PTT**, **SK1** (upper side key), **SK2** (lower side
key), the **top / orange** button, and a keypad with **green** (✓ / menu), **red**
(✗ / back), arrows, `0``9`, `*` and `#`.
| Control | Action |
| --- | --- |
| **Green** | Menu / confirm |
| **Red** | Back / exit |
| **Top / orange** button | Open the **quick menu** (VFO or Channel) — ⚪ [OpenGD77] |
| **SK1** (hold) | Show frequency / secondary info |
| **SK2** (hold) | Modifier for other keys |
| **SK2** (short tap) | **Toggle mute** on VFO/Channel — 🟢 [FreeTRX] |
| **SK2** (extra-long) | Monitor / open squelch — ⚪ [OpenGD77] |
| **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] |
---
## Display & UI
### Segmented S-meter — 🟢 [FreeTRX]
The signal meter under the top status bar is drawn as discrete blocks: **9 green**
blocks for S1S9 (4 dB each) and **6 red** blocks for S9+10…+60 (10 dB each), with
a scale-label row beneath (`S1 S3 S5 S7 S9 / +20 +40 +60`). The leading block fills
proportionally.
### 7-segment "LED" frequency font — 🟢 [FreeTRX]
On the VFO and the SK1-held frequency view, the frequency is rendered in a
7-segment "LED" style (lit segments only). "MHz" matches the frequency colour
(green on RX, red on TX).
### Screen dimming — 🟢 [FreeTRX]
Optionally dims the backlight after inactivity to **100 / 50 / 30 / 15 %** of the
configured brightness (only in **Auto** backlight mode). Any activity restores full
brightness. Set it in **Menu → Options → Display Options → Screen Dim**.
### Shut-off animation — 🟢 [FreeTRX]
Powering off collapses whatever is on screen towards the middle — squeezed to a
line, then a dot — like an old CRT switching off, in place of the "Power Off"
message. (Once the animation starts, the power-off is committed.)
### Theme presets — 🟢 [FreeTRX]
Pick a colour theme on the radio at **Menu → Options → Display Options → "Theme"**
(◀/▶ to change; the screen updates live):
- **Custom** — the theme edited via the CPS or the on-radio colour editor (default).
- **BASIC 80s** — a Commodore-64 look (light blue on blue).
- **Terminal** — a green terminal (green-on-black at night, black-on-green by day).
- **Vertexic** — amber/orange (black-on-amber by day, amber-on-black at night).
- **Red** — black-on-red by day, red-on-black at night.
- **Neon 80s** — an 80s-synthwave palette with several neon colours (cyan,
magenta, violet, green, yellow) across the UI over a dark background.
- **Pride** — the background is painted as a rainbow pride flag; body text is
black over the stripes, and the menu title, header and notifications use white
boxes so they stay readable. (Text drawn directly over the brightest stripes
has lower contrast than a solid-background theme — that's inherent to a
rainbow background.)
Editing colours in the CPS only affects **Custom**; the built-in presets are
fixed. Every element (S-meter, frequencies, menus) follows the selected theme.
### Mute indicator — 🟢 [FreeTRX]
While muted, the **red LED flashes** (0.5 s every 2 s). See *Mute* below.
---
## Audio
### One-tap mute — 🟢 [FreeTRX]
- A **short tap of SK2** on the VFO/Channel screen toggles the speaker mute on/off.
(Exception: while a DMR QSO is on screen, SK2 keeps its "set TX talkgroup to the
incoming one" role.)
- Turning the **volume knob fully down** also counts as muted.
- Mute is shown by the **"M"** indicator and the flashing red LED (above).
- Mute is also available in the Channel/VFO **quick menu**.
---
## Operating
### VFO & Channel modes — ⚪ [OpenGD77]
Switch between direct-frequency **VFO** mode and stored **Channel** mode with the
mode toggle. Most operating happens here.
### Spectrum scope (VFO sweep) — ⚪ [OpenGD77]
A real-time signal-strength-vs-frequency graph centred on the VFO frequency.
- **Enter/exit:** in **VFO mode**, **hold `#`** (long press). Hold `#` again to leave.
- **Left / Right:** move the centre frequency across the band.
- **Up / Down:** change the sweep **span / step** (zoom in / out).
- **SK2 + Up/Down:** adjust **gain**; **SK2 + Left/Right:** adjust the
**noise-floor** reference (how the trace fills the screen).
- The gain / noise-floor / step settings are remembered.
- The scope is **VFO-only** (not available in Channel mode).
### Scanning — ⚪ [OpenGD77]
**There are no scan lists.** Unlike the vendor CPS, OpenGD77 scans **the channels
of the currently selected zone** — so a zone *is* the scan list. To scan a
particular set of channels, put them in a zone and select it. The **All Channels**
virtual zone scans every channel in the codeplug.
- **Start/stop:** long-press **UP** on the Channel or VFO screen (**RED** stops).
- **Exclude a channel:** **Menu → Channel details** (or **SK2 + green** on the
Channel screen) → **Zone Skip** (skipped when scanning a normal zone) and
**All Skip** (skipped when scanning *All Channels*).
- **Settings:** **Menu → Options → Radio options****Scan delay**,
**Scan dwell**, **Scan mode** (Hold / Pause / Stop) and **Scan On Boot**.
Note: in the codeplug these two skip flags reuse the old "Auto Scan" and "Lone
Worker" bits, so a vendor CPS may label them differently — set them on the radio
to avoid confusion.
### Quick menu — ⚪ [OpenGD77] (with 🟢 [FreeTRX] additions)
Press the **top / orange** button to open the quick menu for the current screen
(VFO or Channel). It holds filters (FM/DMR), Talkaround, Roaming, Mute, VFO↔Channel
copy, and — on the Channel screen — the FreeTRX **new channel** / **Delete Channel**
items (see below).
---
## Channels & zones
### Channel Details editor — ⚪ [OpenGD77]
Edit an existing channel's parameters (name, frequencies, tones, power, bandwidth,
timeslot, colour code, groups, …). From the Channel screen, press **SK2 + green**.
Press **green** to save.
### Create & delete channels on the radio — 🟢 [FreeTRX]
No PC/CPS required. Open the **Channel quick menu** (top/orange button) and scroll
to the bottom:
- **New channel** — creates a blank **145.500 MHz FM** channel in the first free
slot, adds it to the current zone, and opens **Channel Details** to edit it. The
new channel is immediately selectable in the zone.
- **Delete Channel** — deletes the selected channel after a confirmation (**green**
confirms, **red** cancels). It is also removed from every zone that referenced it.
The last remaining channel can't be deleted, and read-only/APRS channels are
protected.
> **Back up your codeplug** (via the CPS) before bulk edits — changes are written
> straight to the radio and there is no on-device undo.
### Zones — ⚪ [OpenGD77]
Select zones from **Menu → Zone**. The virtual **All Channels** zone lists every
channel. (On-radio *zone* editing is planned for a future FreeTRX release.)
---
## DMR
### DMR operation — ⚪ [OpenGD77]
Talkgroups/contacts, timeslots, colour codes, RX groups, and hotspot mode, all per
upstream OpenGD77. The AMBE voice codec is merged in at flash time (see
[`TOOLS.md`](TOOLS.md)).
### Contacts list — 🟢 [FreeTRX]
**Menu → Contacts** lists all contact types together under the title "Contacts"
(instead of defaulting to the group filter). Press **`#`** to cycle the filter:
all → groups → private → all-call → all.
### DMR reliability fix — 🟢 [FreeTRX]
FreeTRX resolves a hard-fault (freeze needing a battery pull) on the DMR path that
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:** in the recipient field, either type the destination DMR ID with
the number keys and press **DOWN** to move on to the message, or press **GREEN**
to pick a recipient from your private-call DMR contacts (selecting one drops you
straight into the message text). 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:** up to 8 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]
**Menu → Firmware info** shows the **FreeTRX version** (semantic versioning,
`FreeTRX vMAJOR.MINOR.PATCH`) alongside the git commit ID. Pre-1.0 builds are
published as pre-releases. See [`CHANGELOG.md`](CHANGELOG.md) for the full
history.
---
*Building, flashing and tools are covered in [`TOOLS.md`](TOOLS.md); the project
overview is in [`README.md`](README.md).*

View file

@ -5,9 +5,9 @@
#define _FREETRX_VERSION_H_
#define FREETRX_VERSION_MAJOR 0
#define FREETRX_VERSION_MINOR 2
#define FREETRX_VERSION_MINOR 11
#define FREETRX_VERSION_PATCH 0
#define FREETRX_VERSION "0.2.0"
#define FREETRX_VERSION "0.11.0"
#endif // _FREETRX_VERSION_H_

View file

@ -353,6 +353,7 @@ typedef enum
CODEPLUG_CUSTOM_DATA_TYPE_SATELLITE_TLE,
CODEPLUG_CUSTOM_DATA_TYPE_THEME_DAY,
CODEPLUG_CUSTOM_DATA_TYPE_THEME_NIGHT,
CODEPLUG_CUSTOM_DATA_TYPE_THEME_PRESET, // FreeTRX: 1 byte, selected theme preset (themePreset_t)
} CodeplugCustomDataType_t;
@ -415,6 +416,7 @@ void codeplugGetVFO_ChannelData(CodeplugChannel_t *vfoBuf, Channel_t VFONumber);
void codeplugSetVFO_ChannelData(CodeplugChannel_t *vfoBuf, Channel_t VFONumber);
bool codeplugAllChannelsIndexIsInUse(int index);
void codeplugAllChannelsIndexSetUsed(int index);
void codeplugAllChannelsIndexSetUnused(int index);
bool codeplugChannelSaveDataForIndex(int index, CodeplugChannel_t *channelBuf);
CodeplugCSSTypes_t codeplugGetCSSType(uint16_t tone);
void codeplugConvertChannelInternalToCodeplug(CodeplugChannel_t *codeplugChannel, CodeplugChannel_t *internalChannel);
@ -442,6 +444,7 @@ bool codeplugGetGeneralSettings(CodeplugGeneralSettings_t *generalSettingsBuffer
bool codeplugGetSignallingDTMF(CodeplugSignallingDTMF_t *signallingDTMFBuffer);
bool codeplugGetSignallingDTMFDurations(CodeplugSignallingDTMFDurations_t *signallingDTMFDurationsBuffer);
bool codeplugZoneAddChannelToZoneAndSave(int channelIndex, CodeplugZone_t *zoneBuf);
bool codeplugZoneRemoveChannelFromZoneAndSave(int channelIndex, CodeplugZone_t *zoneBuf);
bool codeplugGetDeviceInfo(CodeplugDeviceInfo_t *deviceInfoBuffer);
uint16_t codeplugGetQuickkeyFunctionID(char key);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -269,8 +269,25 @@ void displayDrawFrequencySevenSeg(int16_t x, int16_t y, int16_t cellW, int16_t c
#endif
#if defined(HAS_COLOURS)
// FreeTRX selectable theme presets. CUSTOM is the CPS-editable theme (default);
// the others are fixed palettes baked into the firmware.
typedef enum
{
THEME_PRESET_CUSTOM = 0, // editable via CPS / on-device colour editor
THEME_PRESET_BASIC80S, // Commodore 64 BASIC (light blue on blue)
THEME_PRESET_TERMINAL, // green terminal (green-on-black night / black-on-green day)
THEME_PRESET_VERTEXIC, // amber/orange (black-on-amber day / amber-on-black night)
THEME_PRESET_RED, // red (black-on-red day / red-on-black night)
THEME_PRESET_NEON80S, // 80s synthwave: cyan/magenta/violet/green neon on dark
THEME_PRESET_PRIDE, // pride flag: black text + white chrome over a rainbow background
NUM_THEME_PRESETS
} themePreset_t;
void themeInitToDefaultValues(DayTime_t daytime, bool invert);
void themeInit(bool SPIFlashAvailable);
void themeApplyPreset(uint8_t preset); // rebuild themeItems[] for the given preset
uint8_t themeGetPreset(void); // read the stored preset (default CUSTOM)
void themeSetPreset(uint8_t preset, bool save); // apply, and optionally persist to the codeplug
void displayThemeApply(themeItem_t fgItem, themeItem_t bgItem);
void displayThemeResetToDefault(void);
bool displayThemeIsForegroundColourEqualTo(themeItem_t fgItem);

View file

@ -47,6 +47,7 @@ void torchToggle(void);
#endif
void LedWrite(LEDs_t theLED, uint8_t output);
void LedGreenSuppress(bool suppress); // hold the green LED off regardless of other writers
uint8_t LedRead(LEDs_t theLED);
void LedWriteDirect(LEDs_t theLED, uint8_t output);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -172,9 +172,6 @@ typedef struct
void menuDisplayTitle(const char *title);
void menuDisplayEntry(int loopOffset, int focusedItem, const char *entryText, int32_t optStart, themeItem_t fgItem, themeItem_t fgOptItem, themeItem_t bgItem);
#if defined(HAS_COLOURS)
void menuSetNextEntryIconByMenuNum(int menuNum); // sets the icon the next menuDisplayEntry() will draw (0/none for menus without an icon)
#endif
// menuGetMenuOffset() return values
#define MENU_OFFSET_BEFORE_FIRST_ENTRY -1
@ -270,6 +267,9 @@ bool uiNotificationHasTimedOut(void);
bool uiNotificationIsVisible(void);
void uiNotificationHide(bool immediateRender);
uiNotificationID_t uiNotificationGetId(void);
#if defined(HAS_COLOURS)
uint16_t *uiNotificationGetOffscreenBuffer(void); // full-screen scratch (borrowed by the shutdown animation)
#endif
#if defined(HAS_GPS)
@ -323,7 +323,16 @@ 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,
MENU_SMS_CONTACT,
UI_SMS_RX_POPUP,
UI_MESSAGE_BOX,
UI_HOTSPOT_MODE,
UI_CPS,
@ -356,6 +365,8 @@ enum CHANNEL_SCREEN_QUICK_MENU_ITEMS
CH_SCREEN_QUICK_MENU_TALKAROUND,
CH_SCREEN_QUICK_MENU_ROAMING,
CH_SCREEN_QUICK_MENU_AUDIO_MUTE,
CH_SCREEN_QUICK_MENU_NEW_CHANNEL,
CH_SCREEN_QUICK_MENU_DELETE_CHANNEL,
NUM_CH_SCREEN_QUICK_MENU_ITEMS
};
@ -438,6 +449,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);
@ -484,4 +496,16 @@ 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 menuSMSContact(uiEvent_t *ev, bool isFirstRun); // pick an SMS recipient from the DMR contacts
menuStatus_t uiSMSPopup(uiEvent_t *ev, bool isFirstRun);
void menuSMSComposePrefill(uint32_t dstId, const char *text); // 0 / NULL leave the field untouched
void menuSMSComposeSetDestination(uint32_t dstId); // set only the recipient, keep any typed text
void menuSMSComposeSetQuickTextEditTarget(int slotIndex); // compose edits a quick text instead of sending
void menuSMSListSetViewTarget(bool sentBox, uint8_t recordIndex);
#endif

View file

@ -39,6 +39,7 @@
#include "io/Leds.h"
#include "interfaces/adc.h"
#include "hardware/radioHardwareInterface.h"
#include "hardware/HR-C6000.h" // slotState, for restoring the green RX LED after unmute
#include "io/buttons.h"
#include "usb/usb_com.h"
@ -52,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"
@ -62,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;
@ -505,6 +570,9 @@ void applicationMainTask(void)
}
#endif
smsStorageInit();
smsCoreInit();
menuSystemInit(getRtcTime_custom());
#if defined(HAS_GPS)
@ -1030,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)&&
@ -1378,6 +1447,9 @@ void applicationMainTask(void)
voxTick();
gpsTick();
aprsBeaconingTick(&ev);
smsCoreTick();
smsStorageTick();
smsHandleEvents();
settingsSaveIfNeeded(false);
if (settingsIsOptionBitSet(BIT_DISPLAY_TIME_IN_HEADER))
@ -1440,16 +1512,31 @@ void applicationMainTask(void)
if (audioAmpIsMuted() || (lastVolume == -99)) // explicit mute or volume knob fully down
{
bool flashOn = ((ticksGetMillis() % 2000) < 500);
LedWrite(LED_GREEN, 0);
LedWrite(LED_RED, (flashOn ? 1 : 0));
if (!muteFlashActive)
{
// Hold the green LED off for the whole mute, so the DMR RX handler
// (which re-asserts green as soon as it reads it off) can't make it
// flicker green between the red flashes.
LedGreenSuppress(true);
muteFlashActive = true;
}
bool flashOn = ((ticksGetMillis() % 2000) < 500);
LedWrite(LED_RED, (flashOn ? 1 : 0));
}
else if (muteFlashActive)
{
// Just unmuted: release our forced LED state.
// Just unmuted: release the green hold, stop the red flash and restore
// the green LED to the current RX state, so a QSO in progress lights
// green again instead of staying dark. Green is normally lit for an
// active DMR slot (matching our filter) or a received FM/digital signal;
// if nothing is being received it stays off. The normal RX code then
// keeps it correct.
LedGreenSuppress(false);
bool rxGreen = (slotState == DMR_STATE_RX_1) || (slotState == DMR_STATE_RX_2) ||
currentRadioDevice->analogSignalReceived || currentRadioDevice->digitalSignalReceived;
LedWrite(LED_RED, 0);
LedWrite(LED_GREEN, 0);
LedWrite(LED_GREEN, (rxGreen ? 1 : 0));
muteFlashActive = false;
}
}

View file

@ -383,6 +383,46 @@ bool codeplugZoneAddChannelToZoneAndSave(int channelIndex, CodeplugZone_t *zoneB
return false;
}
bool codeplugZoneRemoveChannelFromZoneAndSave(int channelIndex, CodeplugZone_t *zoneBuf)
{
// Not valid for the virtual "All Channels" zone (indexNumber -1): its membership
// is the channel in-use bitmap, not a stored channels[] list.
if (zoneBuf->NOT_IN_CODEPLUGDATA_indexNumber < 0)
{
return false;
}
// Find the channel in the zone's list.
int found = -1;
for (int i = 0; i < zoneBuf->NOT_IN_CODEPLUGDATA_numChannelsInZone; i++)
{
if (zoneBuf->channels[i] == channelIndex)
{
found = i;
break;
}
}
if (found == -1)
{
return false; // channel is not a member of this zone
}
// Shift the remaining channels down to close the gap, then zero the freed tail slot
// (a zero also terminates the list for codeplugZoneGetDataForNumber()).
for (int i = found; i < (zoneBuf->NOT_IN_CODEPLUGDATA_numChannelsInZone - 1); i++)
{
zoneBuf->channels[i] = zoneBuf->channels[i + 1];
}
zoneBuf->channels[zoneBuf->NOT_IN_CODEPLUGDATA_numChannelsInZone - 1] = 0;
zoneBuf->NOT_IN_CODEPLUGDATA_numChannelsInZone--;
zoneBuf->NOT_IN_CODEPLUGDATA_highestIndex = zoneBuf->NOT_IN_CODEPLUGDATA_numChannelsInZone;
// IMPORTANT. Write size is different from the size of the data (see the add function).
return EEPROM_Write(CODEPLUG_ADDR_EX_ZONE_LIST + (zoneBuf->NOT_IN_CODEPLUGDATA_indexNumber * (16 + (sizeof(uint16_t) * codeplugChannelsPerZone))),
(uint8_t *)zoneBuf, ((codeplugChannelsPerZone == 16) ? CODEPLUG_ZONE_DATA_ORIGINAL_STRUCT_SIZE : CODEPLUG_ZONE_DATA_OPENGD77_STRUCT_SIZE));
}
static uint16_t codeplugAllChannelsGetCount(void)
{
uint16_t c = 0;
@ -447,6 +487,48 @@ void codeplugAllChannelsIndexSetUsed(int index)
}
}
void codeplugAllChannelsIndexSetUnused(int index)
{
if ((index >= CODEPLUG_CHANNELS_MIN) && (index <= CODEPLUG_CHANNELS_MAX))
{
index--;
int channelBank = (index / CODEPLUG_CHANNELS_PER_BANK);
int byteno = (index % CODEPLUG_CHANNELS_PER_BANK) / 8;
int cacheOffset = index / 8;
codeplugAllChannelsCache[cacheOffset] &= ~(1 << (index % 8));
if(channelBank == 0)
{
EEPROM_Write(CODEPLUG_ADDR_CHANNEL_HEADER_EEPROM + byteno, &codeplugAllChannelsCache[cacheOffset], 1);
}
else
{
SPI_Flash_write(FLASH_ADDRESS_OFFSET + (CODEPLUG_ADDR_CHANNEL_HEADER_FLASH + ((channelBank - 1) *
(CODEPLUG_CHANNELS_PER_BANK * CODEPLUG_CHANNEL_DATA_STRUCT_SIZE + 16))) + byteno, &codeplugAllChannelsCache[cacheOffset], 1);
}
if (allChannelsTotalNumOfChannels > 0)
{
allChannelsTotalNumOfChannels--;
}
// If we just freed the highest-numbered channel, rescan for the new highest.
if ((index + 1) >= allChannelsHighestChannelIndex)
{
allChannelsHighestChannelIndex = 0;
for (int i = CODEPLUG_CHANNELS_MAX; i >= CODEPLUG_CHANNELS_MIN; i--)
{
if (codeplugAllChannelsIndexIsInUse(i))
{
allChannelsHighestChannelIndex = i;
break;
}
}
}
}
}
void codeplugAllChannelsInitCache(void)
{
// There are 8 banks

View file

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

View file

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

View file

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

View file

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

View file

@ -343,13 +343,34 @@ void voicePromptsAppendLanguageString(const char *languageStringAdd)
return;
}
voicePromptsAppendPrompt(NUM_VOICE_PROMPTS +
// The prompt index is derived from the pointer's offset within the currentLanguage
// table, so a pointer that isn't actually inside that table (e.g. a raw string
// literal passed by mistake) would yield an out-of-range prompt and crash. Ignore it.
const char *languageBase = &currentLanguage->LANGUAGE_NAME[0];
if ((languageStringAdd < languageBase) || (languageStringAdd >= (languageBase + sizeof(*currentLanguage))))
{
return;
}
int promptNumber = NUM_VOICE_PROMPTS +
((languageStringAdd - currentLanguage->LANGUAGE_NAME)
#if ! defined(HAS_COLOURS)
- ((languageStringAdd >= currentLanguage->theme_chooser) ?
((currentLanguage->theme_colour_picker_blue - currentLanguage->theme_chooser) + LANGUAGE_TEXTS_LENGTH) : 0)
#endif
) / LANGUAGE_TEXTS_LENGTH);
) / LANGUAGE_TEXTS_LENGTH;
// Language strings added after the voice prompt pack was built have no
// entry in the table of contents. voicePromptsPlay() reads TOC[promptNumber + 1],
// so anything at or past the last slot would read past the array and crash;
// play silence for those instead.
if ((promptNumber < 0) || (promptNumber >= (VOICE_PROMPTS_TOC_SIZE - 1)))
{
voicePromptsAppendPrompt(PROMPT_SILENCE);
return;
}
voicePromptsAppendPrompt(promptNumber);
}
void voicePromptsPlay(void)

View file

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

View file

@ -67,6 +67,21 @@ static uint16_t backgroundColour = 0xFFFFU;
static uint16_t screenBufData[DISPLAY_SIZE_X * DISPLAY_SIZE_Y];
uint16_t *screenBuf = screenBufData;
#if defined(HAS_COLOURS)
// When the Pride theme is active the background canvas is painted as six
// horizontal rainbow stripes instead of a single flat colour.
static bool prideBackgroundActive = false;
static uint16_t prideStripes[6];
// Pride stripe colour for a linear screen-buffer index (row = index / width).
static inline uint16_t pridePixelColour(int index)
{
int band = ((index / DISPLAY_SIZE_X) * 6) / DISPLAY_SIZE_Y;
return prideStripes[(band < 0) ? 0 : ((band > 5) ? 5 : band)];
}
#endif
//#define DISPLAY_CHECK_BOUNDS
#ifdef DISPLAY_CHECK_BOUNDS
@ -326,6 +341,16 @@ int displayPrintCoreDoubleHeight(int16_t xPos, int16_t yPos, const char *szMsg,
void displayClearBuf(void)
{
#if defined(HAS_COLOURS)
if (prideBackgroundActive)
{
for (int i = 0; i < DISPLAY_SIZE_X * DISPLAY_SIZE_Y; i++)
{
screenBuf[i] = pridePixelColour(i);
}
return;
}
#endif
// may be able to do this using DMA
for(int i = 0; i < DISPLAY_SIZE_X * DISPLAY_SIZE_Y; i++)
{
@ -353,7 +378,11 @@ void displayClearRows(int16_t startRow, int16_t endRow, bool isInverted)
for(int i = startRow; i < endRow; i++)
{
#if defined(HAS_COLOURS)
screenBuf[i] = ((prideBackgroundActive && (isInverted == false)) ? pridePixelColour(i) : fillColour);
#else
screenBuf[i] = fillColour;
#endif
}
}
@ -1510,28 +1539,210 @@ void themeInitToDefaultValues(DayTime_t daytime, bool invert)
themeItems[daytime][THEME_ITEM_FG_BD_COLOUR] = PLATFORM_COLOUR_FORMAT_SWAP_BYTES(RGB888_TO_PLATFORM_COLOUR_FORMAT(0xFF0000U));
}
void themeInit(bool SPIFlashAvailable)
// Set every theme item to fgRGB, then override the background items with bgRGB, for
// a cohesive single-palette look. The selected-menu-item background is intentionally
// left as the foreground colour so the highlighted row still inverts.
static void themeSetAllItems(DayTime_t daytime, uint32_t fgRGB, uint32_t bgRGB)
{
uint16_t fg = PLATFORM_COLOUR_FORMAT_SWAP_BYTES(RGB888_TO_PLATFORM_COLOUR_FORMAT(fgRGB));
uint16_t bg = PLATFORM_COLOUR_FORMAT_SWAP_BYTES(RGB888_TO_PLATFORM_COLOUR_FORMAT(bgRGB));
for (int i = 0; i < THEME_ITEM_MAX; i++)
{
themeItems[daytime][i] = fg;
}
themeItems[daytime][THEME_ITEM_BG] = bg;
themeItems[daytime][THEME_ITEM_BG_SPLASHSCREEN] = bg;
themeItems[daytime][THEME_ITEM_BG_NOTIFICATION] = bg;
themeItems[daytime][THEME_ITEM_BG_MENU_NAME] = bg;
themeItems[daytime][THEME_ITEM_BG_HEADER_TEXT] = bg;
// THEME_ITEM_BG_MENU_ITEM_SELECTED intentionally left = fg (inverts the selected row).
}
static void themeSetItem(DayTime_t daytime, themeItem_t item, uint32_t rgb)
{
themeItems[daytime][item] = PLATFORM_COLOUR_FORMAT_SWAP_BYTES(RGB888_TO_PLATFORM_COLOUR_FORMAT(rgb));
}
// 80s synthwave: a dark background with several neon accents mapped onto the
// individual UI elements. Both day and night use the same neon palette so the
// look is preserved; only the background darkens for night.
static void themeSetNeon80s(DayTime_t daytime, uint32_t bg)
{
const uint32_t cyan = 0x00F0FF;
const uint32_t magenta = 0xFF2CA5;
const uint32_t green = 0x39FF14;
const uint32_t violet = 0xB16CFF;
const uint32_t yellow = 0xFFE600;
const uint32_t red = 0xFF3131;
const uint32_t orange = 0xFF7A18;
// Base: cyan text on the dark background, everything else overridden below.
themeSetAllItems(daytime, cyan, bg);
themeSetItem(daytime, THEME_ITEM_FG_DECORATION, magenta); // borders / separators / drop shadow
themeSetItem(daytime, THEME_ITEM_FG_TEXT_INPUT, green);
themeSetItem(daytime, THEME_ITEM_FG_SPLASHSCREEN, magenta);
themeSetItem(daytime, THEME_ITEM_FG_NOTIFICATION, cyan);
themeSetItem(daytime, THEME_ITEM_FG_WARNING_NOTIFICATION, yellow);
themeSetItem(daytime, THEME_ITEM_FG_ERROR_NOTIFICATION, red);
themeSetItem(daytime, THEME_ITEM_FG_MENU_NAME, magenta); // menu header
themeSetItem(daytime, THEME_ITEM_BG_MENU_ITEM_SELECTED, magenta); // selected row = magenta bar
themeSetItem(daytime, THEME_ITEM_FG_OPTIONS_VALUE, green);
themeSetItem(daytime, THEME_ITEM_FG_HEADER_TEXT, violet); // mode/PWR/battery header
themeSetItem(daytime, THEME_ITEM_FG_RSSI_BAR, cyan);
themeSetItem(daytime, THEME_ITEM_FG_RSSI_BAR_S9P, magenta); // over S9 turns hot pink
themeSetItem(daytime, THEME_ITEM_FG_CHANNEL_NAME, yellow);
themeSetItem(daytime, THEME_ITEM_FG_CHANNEL_CONTACT, magenta);
themeSetItem(daytime, THEME_ITEM_FG_CHANNEL_CONTACT_INFO, cyan);
themeSetItem(daytime, THEME_ITEM_FG_ZONE_NAME, violet);
themeSetItem(daytime, THEME_ITEM_FG_RX_FREQ, cyan);
themeSetItem(daytime, THEME_ITEM_FG_TX_FREQ, red);
themeSetItem(daytime, THEME_ITEM_FG_CSS_SQL_VALUES, green);
themeSetItem(daytime, THEME_ITEM_FG_TX_COUNTER, yellow);
themeSetItem(daytime, THEME_ITEM_FG_POLAR_DRAWING, violet);
themeSetItem(daytime, THEME_ITEM_FG_SATELLITE_COLOUR, magenta);
themeSetItem(daytime, THEME_ITEM_FG_GPS_NUMBER, cyan);
themeSetItem(daytime, THEME_ITEM_FG_GPS_COLOUR, green);
themeSetItem(daytime, THEME_ITEM_FG_BD_COLOUR, orange);
}
// Pride: the background canvas is painted as a rainbow (see pridePixelColour);
// the theme items only need black text plus white "chrome" boxes (menu title,
// header, notifications) so those stay readable over any stripe. The selected
// menu row already inverts to a black bar with white text.
static void themeSetPride(DayTime_t daytime)
{
themeSetAllItems(daytime, 0x000000, 0xFFFFFF); // black text, white chrome backgrounds
// Paint the status/header bar and the menu title bar in the top stripe
// colour (prideRGB[0]) so they blend into the rainbow instead of showing
// as white boxes.
themeSetItem(daytime, THEME_ITEM_BG_HEADER_TEXT, 0xE40303);
themeSetItem(daytime, THEME_ITEM_BG_MENU_NAME, 0xE40303);
// Keep warning/error notifications colour-coded on their white box.
themeSetItem(daytime, THEME_ITEM_FG_WARNING_NOTIFICATION, 0xC07800);
themeSetItem(daytime, THEME_ITEM_FG_ERROR_NOTIFICATION, 0xC00000);
}
void themeApplyPreset(uint8_t preset)
{
// Start from the monochrome defaults, then overlay the chosen preset.
themeInitToDefaultValues(NIGHT, true);
themeInitToDefaultValues(DAY, false);
// Read and apply user's theme, if any
if (SPIFlashAvailable)
prideBackgroundActive = false; // only the Pride preset re-enables the rainbow canvas
switch (preset)
{
case THEME_PRESET_BASIC80S: // Commodore 64 BASIC: light blue on blue (day & night)
themeSetAllItems(DAY, 0x7C71D5, 0x3E31A2);
themeSetAllItems(NIGHT, 0x7C71D5, 0x3E31A2);
break;
case THEME_PRESET_TERMINAL:
themeSetAllItems(DAY, 0x000000, 0x00A000); // black on green
themeSetAllItems(NIGHT, 0x00E000, 0x000000); // green on black
break;
case THEME_PRESET_VERTEXIC: // orange (Vertex-style)
themeSetAllItems(DAY, 0x000000, 0xFF6A00); // black on orange
themeSetAllItems(NIGHT, 0xFF6A00, 0x000000); // orange on black
break;
case THEME_PRESET_RED:
themeSetAllItems(DAY, 0x000000, 0xFF0000); // black on red
themeSetAllItems(NIGHT, 0xFF0000, 0x000000); // red on black
break;
case THEME_PRESET_NEON80S: // 80s synthwave neon on dark
themeSetNeon80s(DAY, 0x1B0B2E); // dark indigo
themeSetNeon80s(NIGHT, 0x08040F); // near-black purple
break;
case THEME_PRESET_PRIDE: // rainbow background canvas
{
// Classic 6-stripe pride flag; blue/violet lightened a little so
// black text stays legible over the lower stripes.
static const uint32_t prideRGB[6] = { 0xE40303, 0xFF8C00, 0xFFED00, 0x22A93F, 0x3D7DF0, 0xB05CD6 };
for (int s = 0; s < 6; s++)
{
prideStripes[s] = PLATFORM_COLOUR_FORMAT_SWAP_BYTES(RGB888_TO_PLATFORM_COLOUR_FORMAT(prideRGB[s]));
}
prideBackgroundActive = true;
themeSetPride(DAY);
themeSetPride(NIGHT);
}
break;
case THEME_PRESET_CUSTOM:
default:
// Overlay the user's Custom theme from the codeplug, if present (CPS edits
// only ever touch this).
{
uint16_t themingTmp[THEME_ITEM_MAX];
if (codeplugGetOpenGD77CustomData(CODEPLUG_CUSTOM_DATA_TYPE_THEME_DAY, (uint8_t *) &themingTmp))
{
memcpy(&themeItems[DAY], &themingTmp, sizeof(themingTmp));
}
if (codeplugGetOpenGD77CustomData(CODEPLUG_CUSTOM_DATA_TYPE_THEME_NIGHT, (uint8_t *) &themingTmp))
{
memcpy(&themeItems[NIGHT], &themingTmp, sizeof(themingTmp));
}
}
break;
}
foregroundColour = themeItems[DAY][THEME_ITEM_FG_DEFAULT];
backgroundColour = themeItems[DAY][THEME_ITEM_BG];
}
if (codeplugGetOpenGD77CustomData(CODEPLUG_CUSTOM_DATA_TYPE_THEME_NIGHT, (uint8_t *) &themingTmp))
uint8_t themeGetPreset(void)
{
memcpy(&themeItems[NIGHT], &themingTmp, sizeof(themingTmp));
uint8_t buf[4] = { THEME_PRESET_CUSTOM, 0, 0, 0 };
if (codeplugGetOpenGD77CustomData(CODEPLUG_CUSTOM_DATA_TYPE_THEME_PRESET, buf))
{
return (buf[0] < NUM_THEME_PRESETS) ? buf[0] : THEME_PRESET_CUSTOM;
}
return THEME_PRESET_CUSTOM;
}
void themeSetPreset(uint8_t preset, bool save)
{
if (preset >= NUM_THEME_PRESETS)
{
preset = THEME_PRESET_CUSTOM;
}
themeApplyPreset(preset);
if (save)
{
uint8_t buf[1] = { preset };
codeplugSetOpenGD77CustomData(CODEPLUG_CUSTOM_DATA_TYPE_THEME_PRESET, buf, sizeof(buf));
}
}
void themeInit(bool SPIFlashAvailable)
{
if (SPIFlashAvailable)
{
themeApplyPreset(themeGetPreset());
}
else
{
themeInitToDefaultValues(NIGHT, true);
themeInitToDefaultValues(DAY, false);
}
if (settingsIsOptionBitSet(BIT_AUTO_NIGHT_OVERRIDE) && (uiDataGlobal.daytimeOverridden == NIGHT))

View file

@ -48,9 +48,28 @@ void LEDsInit(void)
#endif
}
// When set, the green LED is held off: any LedWrite(LED_GREEN, 1) is forced to 0.
// Used while muted so the DMR RX handler (which re-asserts green whenever it reads
// it as 0) can't make the green LED flicker between the red mute flashes.
static volatile bool greenLedSuppressed = false;
void LedGreenSuppress(bool suppress)
{
greenLedSuppressed = suppress;
if (suppress)
{
LedWrite(LED_GREEN, 0); // force it off now
}
}
void LedWrite(LEDs_t theLED, uint8_t output)
{
#if ! defined(PLATFORM_GD77S)
if ((theLED == LED_GREEN) && greenLedSuppressed)
{
output = 0; // green is held off (e.g. while muted)
}
LEDsState[theLED] = output;
if (settingsIsOptionBitSet(BIT_ALL_LEDS_DISABLED) == 0)

View file

@ -62,6 +62,58 @@ static menuStatus_t menuContactListSubMenuExitCode = MENU_STATUS_SUCCESS;
static const char *calltypeVoices[3] = { NULL, NULL, NULL };
// Pseudo call-type for the digital list: show every contact (groups, private
// calls and all-calls) together. This is the default; HASH still cycles the
// per-type filters.
#define CONTACT_LIST_COMBINED 0xFFU
// Number of digital contacts under the current filter (all types when combined).
static int digitalContactsCount(void)
{
if (contactCallType == CONTACT_LIST_COMBINED)
{
return codeplugContactsGetCount(CONTACT_CALLTYPE_TG)
+ codeplugContactsGetCount(CONTACT_CALLTYPE_PC)
+ codeplugContactsGetCount(CONTACT_CALLTYPE_ALL);
}
return codeplugContactsGetCount(contactCallType);
}
// Fetch the (1-based) digital contact under the current filter. When combined,
// the list runs groups first, then private calls, then all-calls.
static int digitalContactGetForNumber(int number, CodeplugContact_t *c)
{
if (contactCallType != CONTACT_LIST_COMBINED)
{
return codeplugContactGetDataForNumberInType(number, contactCallType, c);
}
int row = number - 1;
int count = codeplugContactsGetCount(CONTACT_CALLTYPE_TG);
if (row < count)
{
return codeplugContactGetDataForNumberInType(row + 1, CONTACT_CALLTYPE_TG, c);
}
row -= count;
count = codeplugContactsGetCount(CONTACT_CALLTYPE_PC);
if (row < count)
{
return codeplugContactGetDataForNumberInType(row + 1, CONTACT_CALLTYPE_PC, c);
}
row -= count;
return codeplugContactGetDataForNumberInType(row + 1, CONTACT_CALLTYPE_ALL, c);
}
// Voice/title label for the current filter.
static const char *contactCallTypeLabel(void)
{
return ((contactCallType == CONTACT_LIST_COMBINED) ? currentLanguage->contacts : calltypeVoices[contactCallType]);
}
// Apply contact + its TS on selection for TX (contact list of quick list).
static void overrideWithSelectedContact(void)
{
@ -78,7 +130,7 @@ static void overrideWithSelectedContact(void)
static void reloadContactList(contactListContactType_t type)
{
menuDataGlobal.numItems = (type == MENU_CONTACT_LIST_CONTACT_DIGITAL) ? codeplugContactsGetCount(contactCallType) : codeplugDTMFContactsGetCount();
menuDataGlobal.numItems = (type == MENU_CONTACT_LIST_CONTACT_DIGITAL) ? digitalContactsCount() : codeplugDTMFContactsGetCount();
if (menuDataGlobal.numItems > 0)
{
@ -87,7 +139,7 @@ static void reloadContactList(contactListContactType_t type)
menuDataGlobal.currentItemIndex = 0;
}
uiDataGlobal.currentSelectedContactIndex = (type == MENU_CONTACT_LIST_CONTACT_DIGITAL)
? codeplugContactGetDataForNumberInType(menuDataGlobal.currentItemIndex + 1, contactCallType, &contactListContactData)
? digitalContactGetForNumber(menuDataGlobal.currentItemIndex + 1, &contactListContactData)
: codeplugDTMFContactGetDataForNumber(menuDataGlobal.currentItemIndex + 1, &contactListDTMFContactData);
}
else
@ -119,7 +171,7 @@ menuStatus_t menuContactList(uiEvent_t *ev, bool isFirstRun)
// Shows digital contact list if called from "contact list" menu entry, or from <SK2>+# in digital.
// Otherwise displays DTMF contact list
contactListType = ((currentMenu == MENU_CONTACT_LIST) || ((currentMenu == MENU_CONTACT_QUICKLIST) && (trxGetMode() != RADIO_MODE_ANALOG))) ? MENU_CONTACT_LIST_CONTACT_DIGITAL : MENU_CONTACT_LIST_CONTACT_DTMF;
contactCallType = CONTACT_CALLTYPE_TG;
contactCallType = CONTACT_LIST_COMBINED; // default: show all contact types together
dtmfSequenceReset();
}
@ -146,7 +198,7 @@ menuStatus_t menuContactList(uiEvent_t *ev, bool isFirstRun)
{
voicePromptsAppendLanguageString(currentLanguage->dmr_contacts);
voicePromptsAppendPrompt(PROMPT_SILENCE);
voicePromptsAppendLanguageString(calltypeVoices[contactCallType]);
voicePromptsAppendLanguageString(contactCallTypeLabel());
}
else
{
@ -158,7 +210,7 @@ menuStatus_t menuContactList(uiEvent_t *ev, bool isFirstRun)
{
if (contactListType == MENU_CONTACT_LIST_CONTACT_DIGITAL)
{
voicePromptsAppendLanguageString(calltypeVoices[contactCallType]);
voicePromptsAppendLanguageString(contactCallTypeLabel());
voicePromptsAppendPrompt(PROMPT_SILENCE);
}
}
@ -210,7 +262,9 @@ static void updateScreen(bool isFirstRun)
switch (contactListDisplayState)
{
case MENU_CONTACT_LIST_DISPLAY:
menuDisplayTitle((char *) calltypeName[((contactListType == MENU_CONTACT_LIST_CONTACT_DIGITAL) ? contactCallType : 3)]);
menuDisplayTitle((contactListType != MENU_CONTACT_LIST_CONTACT_DIGITAL)
? (char *) calltypeName[3] // "DTMF"
: ((contactCallType == CONTACT_LIST_COMBINED) ? (char *) currentLanguage->contacts : (char *) calltypeName[contactCallType]));
if (menuDataGlobal.numItems == 0)
{
@ -235,7 +289,7 @@ static void updateScreen(bool isFirstRun)
}
idx = (contactListType == MENU_CONTACT_LIST_CONTACT_DIGITAL)
? codeplugContactGetDataForNumberInType(mNum + 1, contactCallType, &contact)
? digitalContactGetForNumber(mNum + 1, &contact)
: codeplugDTMFContactGetDataForNumber(mNum + 1, &dtmfContact);
if (idx > 0)
@ -323,7 +377,7 @@ static void handleEvent(uiEvent_t *ev)
{
menuSystemMenuIncrement(&menuDataGlobal.currentItemIndex, menuDataGlobal.numItems);
uiDataGlobal.currentSelectedContactIndex = (contactListType == MENU_CONTACT_LIST_CONTACT_DIGITAL)
? codeplugContactGetDataForNumberInType(menuDataGlobal.currentItemIndex + 1, contactCallType, &contactListContactData)
? digitalContactGetForNumber(menuDataGlobal.currentItemIndex + 1, &contactListContactData)
: codeplugDTMFContactGetDataForNumber(menuDataGlobal.currentItemIndex + 1, &contactListDTMFContactData);
voicePromptsInit();
updateScreen(false);
@ -333,7 +387,7 @@ static void handleEvent(uiEvent_t *ev)
{
menuSystemMenuDecrement(&menuDataGlobal.currentItemIndex, menuDataGlobal.numItems);
uiDataGlobal.currentSelectedContactIndex = (contactListType == MENU_CONTACT_LIST_CONTACT_DIGITAL)
? codeplugContactGetDataForNumberInType(menuDataGlobal.currentItemIndex + 1, contactCallType, &contactListContactData)
? digitalContactGetForNumber(menuDataGlobal.currentItemIndex + 1, &contactListContactData)
: codeplugDTMFContactGetDataForNumber(menuDataGlobal.currentItemIndex + 1, &contactListDTMFContactData);
voicePromptsInit();
updateScreen(false);
@ -343,11 +397,24 @@ static void handleEvent(uiEvent_t *ev)
{
if (contactListType == MENU_CONTACT_LIST_CONTACT_DIGITAL)
{
contactCallType = (contactCallType + 1) % (CONTACT_CALLTYPE_ALL + 1);
// cycle: all combined -> group -> private -> all-call -> combined
if (contactCallType == CONTACT_LIST_COMBINED)
{
contactCallType = CONTACT_CALLTYPE_TG;
}
else if (contactCallType == CONTACT_CALLTYPE_ALL)
{
contactCallType = CONTACT_LIST_COMBINED;
}
else
{
contactCallType = contactCallType + 1;
}
menuDataGlobal.currentItemIndex = 0;
reloadContactList(contactListType);
voicePromptsInit();
voicePromptsAppendLanguageString(calltypeVoices[contactCallType]);
voicePromptsAppendLanguageString(contactCallTypeLabel());
voicePromptsAppendPrompt(PROMPT_SILENCE);
updateScreen(false);

View file

@ -121,9 +121,6 @@ static void updateScreen(bool isFirstRun)
if (menuDataGlobal.currentMenuList[mNum].stringOffset >= 0)
{
const char *menuName = (currentLanguage->LANGUAGE_NAME + (menuDataGlobal.currentMenuList[mNum].stringOffset * LANGUAGE_TEXTS_LENGTH));
#if defined(HAS_COLOURS)
menuSetNextEntryIconByMenuNum(menuDataGlobal.currentMenuList[mNum].menuNum);
#endif
menuDisplayEntry(i, mNum, menuName, 0, THEME_ITEM_FG_MENU_ITEM, THEME_ITEM_COLOUR_NONE, THEME_ITEM_BG);
if (i == 0)

View file

@ -104,6 +104,9 @@ enum
DISPLAY_SHOW_DISTANCE,
DISPLAY_DMR_LAST_TALKER_ON_SCREEN,
DISPLAY_SCREEN_DIM,
#if defined(HAS_COLOURS)
DISPLAY_THEME_PRESET,
#endif
NUM_DISPLAY_MENU_ITEMS
};
@ -269,6 +272,18 @@ static void updateScreen(bool isFirstRun)
}
break;
#if defined(HAS_COLOURS)
case DISPLAY_THEME_PRESET:
{
// Preset names are plain literals -> rightSideVar (voice-safe).
const char *presetNames[] = { "Custom", "BASIC 80s", "Terminal", "Vertexic", "Red", "Neon 80s", "Pride" };
uint8_t p = themeGetPreset();
leftSide = "Theme";
snprintf(rightSideVar, SCREEN_LINE_BUFFER_SIZE, "%s", presetNames[(p < NUM_THEME_PRESETS) ? p : 0]);
}
break;
#endif
#if ! defined(PLATFORM_GD77S)
case DISPLAY_AUTO_NIGHT:
leftSide = currentLanguage->auto_night;
@ -630,6 +645,19 @@ static void handleEvent(uiEvent_t *ev)
}
break;
#if defined(HAS_COLOURS)
case DISPLAY_THEME_PRESET:
{
uint8_t p = themeGetPreset();
if (p < (NUM_THEME_PRESETS - 1))
{
themeSetPreset(p + 1, true); // apply + persist, live preview
displayThemeResetToDefault();
}
}
break;
#endif
#if ! defined(PLATFORM_GD77S)
case DISPLAY_AUTO_NIGHT:
if (settingsIsOptionBitSet(BIT_AUTO_NIGHT) == false)
@ -839,6 +867,19 @@ static void handleEvent(uiEvent_t *ev)
}
break;
#if defined(HAS_COLOURS)
case DISPLAY_THEME_PRESET:
{
uint8_t p = themeGetPreset();
if (p > THEME_PRESET_CUSTOM)
{
themeSetPreset(p - 1, true); // apply + persist, live preview
displayThemeResetToDefault();
}
}
break;
#endif
#if ! defined(PLATFORM_GD77S)
case DISPLAY_AUTO_NIGHT:
if (settingsIsOptionBitSet(BIT_AUTO_NIGHT))

View file

@ -0,0 +1,364 @@
/*
* Copyright (C) 2026-today Marcus Kida, DK1DA
*
*
* Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions
* are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer
* in the documentation and/or other materials provided with the distribution.
*
* 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived
* from this software without specific prior written permission.
*
* 4. Use of this source code or binary releases for commercial purposes is strictly forbidden. This includes, without limitation,
* incorporation in a commercial product or incorporation into a product or project which allows commercial use.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
*/
#include <stdlib.h>
#include "user_interface/uiGlobals.h"
#include "user_interface/menuSystem.h"
#include "user_interface/uiLocalisation.h"
#include "user_interface/uiUtilities.h"
#include "io/keyboard.h"
#include "functions/smsCore.h"
#include "functions/smsStorage.h"
#define DEST_DIGITS_MAX 8 // 16777215 is the largest 24-bit DMR ID
#define TEXT_CHARS_PER_LINE (DISPLAY_SIZE_X / 8)
#define TEXT_VISIBLE_LINES 5
#define TEXT_AREA_Y (FONT_SIZE_3_HEIGHT * 2)
enum { FIELD_DESTINATION = 0, FIELD_TEXT };
static char composeText[SMS_MAX_TEXT_LENGTH + 1];
static char destDigits[DEST_DIGITS_MAX + 1];
static int textPos = 0;
static int focusedField = FIELD_TEXT;
static int quickTextEditSlot = -1;
// prefill is written straight into the compose buffers, consumed on first run
static bool prefillPending = false;
static int pendingQuickTextEditSlot = -1;
// set while the contact picker is open, so returning from it preserves the
// typed text and only moves to the message field when a contact was chosen
static bool pickerReturnPending = false;
static bool pickerSelected = false;
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 menuSMSComposeSetDestination(uint32_t dstId)
{
if (dstId != 0)
{
snprintf(destDigits, sizeof(destDigits), "%u", (unsigned int)dstId);
}
// a contact was chosen in the picker: on return, keep the text and move
// the focus to the message field
pickerSelected = 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 (pickerReturnPending)
{
// returning from the contact picker: keep the typed text and
// recipient; only advance to the message field if a contact was
// actually selected (a cancel stays on the recipient field)
if (pickerSelected)
{
focusedField = FIELD_TEXT;
}
pickerReturnPending = false;
pickerSelected = false;
}
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))
{
if (quickTextEditSlot >= 0)
{
sendOrSave(); // save the quick-text template
}
else if (focusedField == FIELD_DESTINATION)
{
// in the recipient field, GREEN opens the contact picker;
// preserve any typed text/recipient whether the picker returns
// a selection or is cancelled
keypadAlphaEnable = false;
pickerReturnPending = true;
pickerSelected = false;
menuSystemPushNewMenu(MENU_SMS_CONTACT);
}
else
{
sendOrSave(); // send the message
}
return;
}
else if (KEYCHECK_PRESS(ev->keys, KEY_UP) && (quickTextEditSlot < 0))
{
focusedField = FIELD_DESTINATION;
updateScreen();
return;
}
else if (KEYCHECK_PRESS(ev->keys, KEY_DOWN))
{
focusedField = FIELD_TEXT;
updateScreen();
return;
}
if (focusedField == FIELD_DESTINATION)
{
if (KEYCHECK_PRESS(ev->keys, KEY_LEFT))
{
int len = strlen(destDigits);
if (len > 0)
{
destDigits[len - 1] = 0;
}
updateScreen();
}
else
{
int keyval = menuGetKeypadKeyValue(ev, true);
if ((keyval != 99) && (strlen(destDigits) < DEST_DIGITS_MAX))
{
char digit[2] = { (char)('0' + keyval), 0 };
strcat(destDigits, digit);
updateScreen();
}
}
}
else // FIELD_TEXT
{
if (KEYCHECK_PRESS(ev->keys, KEY_LEFT))
{
moveCursorLeftInString(composeText, &textPos, BUTTONCHECK_DOWN(ev, BUTTON_SK2));
updateScreen();
}
else if (KEYCHECK_PRESS(ev->keys, KEY_RIGHT))
{
moveCursorRightInString(composeText, &textPos, SMS_MAX_TEXT_LENGTH, BUTTONCHECK_DOWN(ev, BUTTON_SK2));
updateScreen();
}
else if ((ev->keys.event & (KEY_MOD_PREVIEW | KEY_MOD_PRESS)) && (ev->keys.key >= 32) && (ev->keys.key <= 126))
{
if (textPos < SMS_MAX_TEXT_LENGTH)
{
bool atEnd = (composeText[textPos] == 0);
composeText[textPos] = ev->keys.key;
if (atEnd)
{
composeText[textPos + 1] = 0;
}
if ((ev->keys.event & KEY_MOD_PRESS) && (textPos < (SMS_MAX_TEXT_LENGTH - 1)))
{
textPos++;
}
updateScreen();
}
}
}
}
}

View file

@ -0,0 +1,161 @@
/*
* 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.
*
*/
/*
* SMS recipient picker: lists the private-call (individual) DMR contacts from
* the codeplug. GREEN selects one as the message destination and returns to the
* compose screen; RED cancels.
*/
#include "user_interface/uiGlobals.h"
#include "user_interface/menuSystem.h"
#include "user_interface/uiLocalisation.h"
#include "user_interface/uiUtilities.h"
#include "functions/codeplug.h"
static menuStatus_t menuSMSContactExitCode = MENU_STATUS_SUCCESS;
static void updateScreen(void);
static void handleEvent(uiEvent_t *ev);
// Fetch the private-call contact for the given list row.
static bool getContactForRow(int row, CodeplugContact_t *contact)
{
return (codeplugContactGetDataForNumberInType(row + 1, CONTACT_CALLTYPE_PC, contact) > 0);
}
menuStatus_t menuSMSContact(uiEvent_t *ev, bool isFirstRun)
{
if (isFirstRun)
{
menuDataGlobal.numItems = codeplugContactsGetCount(CONTACT_CALLTYPE_PC);
menuDataGlobal.currentItemIndex = 0;
updateScreen();
return (MENU_STATUS_LIST_TYPE | MENU_STATUS_SUCCESS);
}
menuSMSContactExitCode = MENU_STATUS_SUCCESS;
if (ev->hasEvent)
{
handleEvent(ev);
}
return menuSMSContactExitCode;
}
static void updateScreen(void)
{
char entryBuffer[SCREEN_LINE_BUFFER_SIZE * 2];
char nameBuffer[SCREEN_LINE_BUFFER_SIZE];
CodeplugContact_t contact;
int mNum;
displayClearBuf();
menuDisplayTitle(currentLanguage->contacts);
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;
}
if (getContactForRow(mNum, &contact))
{
codeplugUtilConvertBufToString(contact.name, nameBuffer, 16);
snprintf(entryBuffer, sizeof(entryBuffer), "%s", nameBuffer);
}
else
{
entryBuffer[0] = 0;
}
menuDisplayEntry(i, mNum, entryBuffer, 0, THEME_ITEM_FG_TEXT_INPUT, THEME_ITEM_COLOUR_NONE, THEME_ITEM_BG);
}
displayRender();
}
static void handleEvent(uiEvent_t *ev)
{
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)
{
CodeplugContact_t contact;
if (getContactForRow(menuDataGlobal.currentItemIndex, &contact))
{
// tgNumber is the plain DMR ID; codeplugContactGetPackedId()
// would OR the call-type flag into the top byte, which is not a
// valid destination ID.
menuSMSComposeSetDestination(contact.tgNumber);
menuSystemPopPreviousMenu(); // back to compose, recipient filled in
return;
}
}
menuSMSContactExitCode |= MENU_STATUS_ERROR;
return;
}
else if (KEYCHECK_PRESS(ev->keys, KEY_DOWN))
{
menuSystemMenuIncrement(&menuDataGlobal.currentItemIndex, menuDataGlobal.numItems);
updateScreen();
}
else if (KEYCHECK_PRESS(ev->keys, KEY_UP))
{
menuSystemMenuDecrement(&menuDataGlobal.currentItemIndex, menuDataGlobal.numItems);
updateScreen();
}
}

View file

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

View file

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

View file

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

View file

@ -90,7 +90,16 @@ 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 contact picker
NULL,// SMS RX popup
NULL,// MessageBox
NULL,// hotspot mode
NULL,// CPS
@ -148,7 +157,16 @@ 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 },
{ menuSMSContact, NULL, NULL, 0 },
{ uiSMSPopup, NULL, NULL, 0 },
{ uiMessageBox, NULL, NULL, 0 },
{ menuHotspotMode, NULL, NULL, 0 },
{ uiCPS, NULL, NULL, 0 },
@ -649,6 +667,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 +686,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 },
@ -725,53 +759,11 @@ void menuDisplayTitle(const char *title)
// optStart:
// - if less than 0, entryText will use fgOptItem colour
// - if equal to 0, no coloured option will be handled
#if defined(HAS_COLOURS)
// 16x16 1-bit main-menu icons (row-major, MSB-first) for displayDrawBitmap.
static const uint8_t menuIcon_zone[32] = { 0x00, 0x00, 0x3F, 0xFC, 0x20, 0x04, 0x2F, 0xF4, 0x20, 0x04, 0x2F, 0xF4, 0x20, 0x04, 0x2F, 0xF4, 0x20, 0x04, 0x2F, 0xF4, 0x20, 0x04, 0x3F, 0xFC, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };
static const uint8_t menuIcon_contacts[32] = { 0x00, 0x00, 0x03, 0xC0, 0x07, 0xE0, 0x05, 0xA0, 0x07, 0xE0, 0x03, 0xC0, 0x01, 0x80, 0x0F, 0xF0, 0x1F, 0xF8, 0x3F, 0xFC, 0x30, 0x0C, 0x30, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };
static const uint8_t menuIcon_channel[32] = { 0x00, 0x00, 0x00, 0x00, 0x21, 0x08, 0x21, 0x08, 0x71, 0x1C, 0x51, 0x14, 0x73, 0x9C, 0x22, 0x88, 0x23, 0x88, 0x21, 0x08, 0x21, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };
static const uint8_t menuIcon_rssi[32] = { 0x00, 0x00, 0x00, 0x06, 0x00, 0x06, 0x00, 0x66, 0x00, 0x66, 0x00, 0x66, 0x06, 0x66, 0x06, 0x66, 0x06, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };
static const uint8_t menuIcon_info[32] = { 0x00, 0x00, 0x07, 0xE0, 0x18, 0x18, 0x21, 0x84, 0x21, 0x84, 0x40, 0x08, 0x43, 0xC2, 0x40, 0xC2, 0x40, 0xC2, 0x40, 0xC2, 0x21, 0xE2, 0x20, 0x04, 0x18, 0x18, 0x07, 0xE0, 0x00, 0x00, 0x00, 0x00 };
static const uint8_t menuIcon_options[32] = { 0x00, 0x00, 0x03, 0xC0, 0x13, 0xC8, 0x1B, 0xD8, 0x3F, 0xFC, 0x38, 0x1C, 0x7B, 0xDE, 0x7B, 0xDE, 0x7B, 0xDE, 0x38, 0x1C, 0x3F, 0xFC, 0x1B, 0xD8, 0x13, 0xC8, 0x03, 0xC0, 0x00, 0x00, 0x00, 0x00 };
static const uint8_t menuIcon_lastheard[32] = { 0x00, 0x00, 0x3F, 0xF8, 0x40, 0x04, 0x4C, 0x62, 0x40, 0x04, 0x4C, 0x62, 0x40, 0x04, 0x4C, 0x62, 0x40, 0x04, 0x3C, 0x7C, 0x04, 0x40, 0x02, 0x80, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };
static const uint8_t menuIcon_radio[32] = { 0x00, 0x10, 0x00, 0x20, 0x00, 0x40, 0x1F, 0xC0, 0x10, 0x40, 0x17, 0x40, 0x17, 0x40, 0x10, 0x40, 0x15, 0x40, 0x15, 0x40, 0x15, 0x40, 0x10, 0x40, 0x1F, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };
static const uint8_t menuIcon_satellite[32] = { 0x00, 0x00, 0x30, 0x00, 0x28, 0x10, 0x24, 0x28, 0x02, 0x44, 0x01, 0x94, 0x00, 0xA4, 0x01, 0x38, 0x02, 0x00, 0x0D, 0x00, 0x11, 0x00, 0x11, 0x00, 0x0E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };
static const uint8_t menuIcon_gps[32] = { 0x00, 0x00, 0x07, 0xE0, 0x18, 0x18, 0x20, 0x04, 0x43, 0xC2, 0x44, 0x22, 0x44, 0x22, 0x44, 0x22, 0x43, 0xC2, 0x20, 0x04, 0x10, 0x08, 0x08, 0x10, 0x04, 0x20, 0x02, 0x40, 0x01, 0x80, 0x00, 0x00 };
static const uint8_t *menuIconForMenuNumber(int menuNum)
{
switch (menuNum)
{
case MENU_ZONE_LIST: return menuIcon_zone;
case MENU_CONTACTS_MENU: return menuIcon_contacts;
case MENU_CHANNEL_DETAILS: return menuIcon_channel;
case MENU_RSSI_SCREEN: return menuIcon_rssi;
case MENU_FIRMWARE_INFO: return menuIcon_info;
case MENU_OPTIONS: return menuIcon_options;
case MENU_LAST_HEARD: return menuIcon_lastheard;
case MENU_RADIO_INFOS: return menuIcon_radio;
case MENU_SATELLITE: return menuIcon_satellite;
case MENU_GPS: return menuIcon_gps;
default: return NULL;
}
}
// One-shot: the next menuDisplayEntry() draws this icon and shifts its text right.
static const uint8_t *nextMenuEntryIcon = NULL;
void menuSetNextEntryIconByMenuNum(int menuNum)
{
nextMenuEntryIcon = menuIconForMenuNumber(menuNum);
}
#endif // HAS_COLOURS
void menuDisplayEntry(int loopOffset, int focusedItem, const char *entryText, int32_t optStart, themeItem_t fgItem, themeItem_t fgOptItem, themeItem_t bgItem)
{
bool focused = (focusedItem == menuDataGlobal.currentItemIndex);
int16_t textXPos = DISPLAY_X_POS_MENU_TEXT_OFFSET;
#if defined(HAS_COLOURS)
const uint8_t *entryIcon = nextMenuEntryIcon;
nextMenuEntryIcon = NULL; // consume it (one-shot)
#endif
if (focused)
{
@ -785,15 +777,6 @@ void menuDisplayEntry(int loopOffset, int focusedItem, const char *entryText, in
displayThemeApply(fgItem, bgItem);
#if defined(HAS_COLOURS)
if (entryIcon != NULL)
{
int16_t iconY = DISPLAY_Y_POS_MENU_ENTRY_HIGHLIGHT + (loopOffset * MENU_ENTRY_HEIGHT);
displayDrawBitmap((DISPLAY_X_POS_MENU_OFFSET + 2), iconY, (uint8_t *)entryIcon, 16, 16, focused);
textXPos = (DISPLAY_X_POS_MENU_OFFSET + 2 + 16 + 3); // icon width + gap
}
#endif
if ((focused == false) && ((optStart < 0) || (optStart > 0)))
{
char buffer[SCREEN_LINE_BUFFER_SIZE];

View file

@ -32,6 +32,7 @@
#include "user_interface/uiLocalisation.h"
#include "functions/voicePrompts.h"
#include "functions/rxPowerSaving.h"
#include "functions/smsCore.h"
#if defined(PLATFORM_MD9600) || defined(PLATFORM_MD380) || defined(PLATFORM_MDUV380) || defined(PLATFORM_RT84_DM1701) || defined(PLATFORM_MD2017)
#include "hardware/radioHardwareInterface.h"
#endif
@ -274,6 +275,13 @@ menuStatus_t uiChannelMode(uiEvent_t *ev, bool isFirstRun)
{
return menuChannelExitStatus;
}
// A new text message arrived
if (smsHasUnseenRxMessage() && (trxTransmissionEnabled == false) && (trxIsTransmitting == false))
{
menuSystemPushNewMenu(UI_SMS_RX_POPUP);
return menuChannelExitStatus;
}
#endif
if (ev->events == NO_EVENT)
@ -1298,6 +1306,20 @@ static void handleEvent(uiEvent_t *ev)
return;
}
else
#else
if (KEYCHECK_LONGDOWN(ev->keys, KEY_GREEN))
{
// Direct access to the DMR text messages
if (uiDataGlobal.Scan.active)
{
uiChannelModeStopScanning();
}
menuSystemPushNewMenu(MENU_SMS);
menuChannelExitStatus |= (MENU_STATUS_LIST_TYPE | MENU_STATUS_FORCE_FIRST);
return;
}
else
#endif
if (KEYCHECK_SHORTUP(ev->keys, KEY_GREEN))
{
@ -2164,6 +2186,152 @@ static bool validateOverwriteChannel(void)
return true;
}
// Shown when channels are locked (read-only / APRS) and an edit is attempted.
static void channelEditShowLockedMessageBox(void)
{
snprintf(uiDataGlobal.MessageBox.message, MESSAGEBOX_MESSAGE_LEN_MAX, "%s\n%s", currentLanguage->aprs_channel, currentLanguage->locked);
uiDataGlobal.MessageBox.type = MESSAGEBOX_TYPE_INFO;
uiDataGlobal.MessageBox.decoration = MESSAGEBOX_DECORATION_FRAME;
uiDataGlobal.MessageBox.buttons =
#if defined(PLATFORM_MD9600)
MESSAGEBOX_BUTTONS_ENT;
#else
MESSAGEBOX_BUTTONS_OK;
#endif
uiDataGlobal.MessageBox.validatorCallback = NULL;
menuSystemPushNewMenu(UI_MESSAGE_BOX);
voicePromptsInit();
voicePromptsAppendLanguageString(currentLanguage->aprs_channel);
voicePromptsAppendLanguageString(currentLanguage->locked);
voicePromptsPlay();
}
// Create a blank/default 145.500 MHz FM simplex channel in the first free slot,
// add it to the current zone, select it and open Channel Details to edit it.
static void channelEditCreateNewChannel(void)
{
int16_t newChannelIndex;
for (newChannelIndex = CODEPLUG_CHANNELS_MIN; newChannelIndex <= CODEPLUG_CHANNELS_MAX; newChannelIndex++)
{
if (codeplugAllChannelsIndexIsInUse(newChannelIndex) == false)
{
break;
}
}
if (newChannelIndex > CODEPLUG_CHANNELS_MAX)
{
nextKeyBeepMelody = (int16_t *)MELODY_NACK_BEEP; // codeplug full, no free slot
return;
}
// Start from the currently loaded channel so all structural fields are valid,
// then override the essentials to a blank FM simplex default.
char nameBuf[SCREEN_LINE_BUFFER_SIZE];
CodeplugChannel_t tempChannel = channelScreenChannelData;
snprintf(nameBuf, SCREEN_LINE_BUFFER_SIZE, "%s %d", currentLanguage->new_channel, newChannelIndex);
memset(&tempChannel.name, 0xFF, sizeof(tempChannel.name)); // codeplug strings are 0xFF-padded
memcpy(&tempChannel.name, nameBuf, strlen(nameBuf));
tempChannel.chMode = RADIO_MODE_ANALOG;
tempChannel.rxFreq = tempChannel.txFreq = 14550000; // 145.500 MHz
tempChannel.txTone = tempChannel.rxTone = CODEPLUG_CSS_TONE_NONE;
tempChannel.sql = 0U; // use the global squelch level
tempChannel.rxGroupList = 0;
tempChannel.contact = 0;
tempChannel.NOT_IN_CODEPLUG_flag = 0x00;
tempChannel.NOT_IN_CODEPLUG_CALCULATED_DISTANCE_X10 = -1;
if (codeplugChannelSaveDataForIndex(newChannelIndex, &tempChannel) == false)
{
nextKeyBeepMelody = (int16_t *)MELODY_NACK_BEEP;
return;
}
codeplugAllChannelsIndexSetUsed(newChannelIndex); // mark the slot used AFTER the data is written
if (currentZone.NOT_IN_CODEPLUGDATA_indexNumber == 0xDEADBEEF)
{
uiChannelInitializeCurrentZone();
}
// Add it to the current zone ("All Channels" already includes every used slot).
if (CODEPLUG_ZONE_IS_ALLCHANNELS(currentZone))
{
codeplugSetLastUsedChannelInZone(currentZone.NOT_IN_CODEPLUGDATA_indexNumber, newChannelIndex);
}
else
{
if (codeplugZoneAddChannelToZoneAndSave(newChannelIndex, &currentZone))
{
codeplugSetLastUsedChannelInZone(currentZone.NOT_IN_CODEPLUGDATA_indexNumber, (currentZone.NOT_IN_CODEPLUGDATA_numChannelsInZone - 1));
}
}
// Reload the zone so the new channel is immediately navigable without having to
// re-enter the zone (refreshes the channel count / highest index for real zones,
// and re-derives them from the channel in-use bitmap for "All Channels").
uiChannelInitializeCurrentZone();
// Select the new channel (kept in memory, so Channel Details edits valid data).
memcpy(&channelScreenChannelData, &tempChannel, sizeof(tempChannel));
uiDataGlobal.currentSelectedChannelNumber = newChannelIndex;
uiDataGlobal.VoicePrompts.inhibitInitial = true;
menuSystemPopAllAndDisplaySpecificRootMenu(UI_CHANNEL_MODE, true);
menuSystemPushNewMenu(MENU_CHANNEL_DETAILS); // jump straight into the editor
nextKeyBeepMelody = (int16_t *)MELODY_ACK_BEEP;
}
// Confirmation callback for deleting the currently-selected channel.
static bool validateDeleteChannel(void)
{
if (uiDataGlobal.MessageBox.keyPressed == KEY_GREEN)
{
int deletedChannelIndex = uiDataGlobal.currentSelectedChannelNumber;
// Remove the channel from every real zone that references it, so no zone is
// left pointing at a freed slot.
int numZones = codeplugZonesGetCount();
for (int z = 0; z < numZones; z++)
{
CodeplugZone_t zoneBuf;
if (codeplugZoneGetDataForNumber(z, &zoneBuf) && (CODEPLUG_ZONE_IS_ALLCHANNELS(zoneBuf) == false))
{
codeplugZoneRemoveChannelFromZoneAndSave(deletedChannelIndex, &zoneBuf);
}
}
// Free the slot (clears the in-use bitmap bit).
codeplugAllChannelsIndexSetUnused(deletedChannelIndex);
// Point the current zone at a valid remaining channel and force a reload.
if (CODEPLUG_ZONE_IS_ALLCHANNELS(currentZone))
{
int firstCh = CODEPLUG_CHANNELS_MIN;
while ((firstCh <= CODEPLUG_CHANNELS_MAX) && (codeplugAllChannelsIndexIsInUse(firstCh) == false))
{
firstCh++;
}
codeplugSetLastUsedChannelInZone(currentZone.NOT_IN_CODEPLUGDATA_indexNumber, ((firstCh <= CODEPLUG_CHANNELS_MAX) ? firstCh : CODEPLUG_CHANNELS_MIN));
}
else
{
codeplugSetLastUsedChannelInZone(currentZone.NOT_IN_CODEPLUGDATA_indexNumber, 0); // first channel in the zone
}
uiChannelInitializeCurrentZone(); // its channel list/count changed
channelScreenChannelData.rxFreq = 0; // force the channel screen to reload
menuSystemPopAllAndDisplaySpecificRootMenu(UI_CHANNEL_MODE, true);
nextKeyBeepMelody = (int16_t *)MELODY_ACK_BEEP;
}
return true;
}
static void updateQuickMenuScreen(bool isFirstRun)
{
int mNum = 0;
@ -2266,6 +2434,12 @@ static void updateQuickMenuScreen(bool isFirstRun)
leftSide = currentLanguage->mute;
rightSideConst = (uiDataGlobal.QuickMenu.tmpAudioMute ? currentLanguage->yes : currentLanguage->no);
break;
case CH_SCREEN_QUICK_MENU_NEW_CHANNEL:
rightSideConst = currentLanguage->new_channel;
break;
case CH_SCREEN_QUICK_MENU_DELETE_CHANNEL:
rightSideConst = currentLanguage->delete_channel;
break;
default:
buf[0] = 0;
}
@ -2496,6 +2670,66 @@ static void handleQuickMenuEvent(uiEvent_t *ev)
}
break;
case CH_SCREEN_QUICK_MENU_NEW_CHANNEL:
if (settingsIsOptionBitSet(BIT_CHANNELS_ARE_READ_ONLY))
{
channelEditShowLockedMessageBox();
}
else
{
channelEditCreateNewChannel();
}
return;
case CH_SCREEN_QUICK_MENU_DELETE_CHANNEL:
{
if (settingsIsOptionBitSet(BIT_CHANNELS_ARE_READ_ONLY))
{
channelEditShowLockedMessageBox();
return;
}
// Don't allow deleting the last remaining channel (would leave the
// codeplug with no channels).
int inUseCount = 0;
for (int i = CODEPLUG_CHANNELS_MIN; (i <= CODEPLUG_CHANNELS_MAX) && (inUseCount < 2); i++)
{
if (codeplugAllChannelsIndexIsInUse(i))
{
inUseCount++;
}
}
uiDataGlobal.MessageBox.type = MESSAGEBOX_TYPE_INFO;
uiDataGlobal.MessageBox.decoration = MESSAGEBOX_DECORATION_FRAME;
if (inUseCount < 2)
{
snprintf(uiDataGlobal.MessageBox.message, MESSAGEBOX_MESSAGE_LEN_MAX, "%s", currentLanguage->keep_one_channel);
uiDataGlobal.MessageBox.buttons =
#if defined(PLATFORM_MD9600)
MESSAGEBOX_BUTTONS_ENT;
#else
MESSAGEBOX_BUTTONS_OK;
#endif
uiDataGlobal.MessageBox.validatorCallback = NULL;
menuSystemPushNewMenu(UI_MESSAGE_BOX);
return;
}
snprintf(uiDataGlobal.MessageBox.message, MESSAGEBOX_MESSAGE_LEN_MAX, "%s\n%s", currentLanguage->delete_channel, currentLanguage->please_confirm);
uiDataGlobal.MessageBox.buttons = MESSAGEBOX_BUTTONS_YESNO;
uiDataGlobal.MessageBox.validatorCallback = validateDeleteChannel;
menuSystemPushNewMenu(UI_MESSAGE_BOX);
voicePromptsInit();
voicePromptsAppendLanguageString(currentLanguage->delete_channel);
voicePromptsAppendLanguageString(currentLanguage->please_confirm);
voicePromptsPlay();
return;
}
break;
default:
// CH_SCREEN_QUICK_MENU_FILTER_FM
if (nonVolatileSettings.analogFilterLevel != uiDataGlobal.QuickMenu.tmpAnalogFilterLevel)

View file

@ -351,6 +351,15 @@ uiNotificationID_t uiNotificationGetId(void)
return notificationData.id;
}
#if defined(HAS_COLOURS)
// Expose the full-screen offscreen buffer so the shutdown animation can borrow
// it as scratch (no notification is active while powering off).
uint16_t *uiNotificationGetOffscreenBuffer(void)
{
return screenNotificationBufData;
}
#endif
static void displayMessage(void)
{
char msg[SCREEN_LINE_BUFFER_SIZE];

View file

@ -25,18 +25,25 @@
* USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
*/
#include <string.h>
#include "user_interface/uiGlobals.h"
#include "user_interface/menuSystem.h"
#include "user_interface/uiLocalisation.h"
#include "functions/ticks.h"
#if defined(PLATFORM_MD9600) || defined(PLATFORM_MD380) || defined(PLATFORM_MDUV380) || defined(PLATFORM_RT84_DM1701) || defined(PLATFORM_MD2017)
#include "interfaces/batteryAndPowerManagement.h"
#include "hardware/radioHardwareInterface.h"
#endif
const uint32_t POWEROFF_DURATION_MILLISECONDS = 500;
#if defined(HAS_COLOURS)
static void crtShutdownAnimation(void);
#else
static uint32_t initialEventTime;
static void updateScreen(void);
static void handleEvent(uiEvent_t *ev);
static uint32_t initialEventTime;
const uint32_t POWEROFF_DURATION_MILLISECONDS = 500;
#endif
menuStatus_t uiPowerOff(uiEvent_t *ev, bool isFirstRun)
{
@ -49,23 +56,103 @@ menuStatus_t uiPowerOff(uiEvent_t *ev, bool isFirstRun)
}
#endif
#if defined(HAS_COLOURS)
// Old-CRT-style collapse of whatever is currently on screen; replaces
// the "Power Off" / 73 message, then shuts down.
crtShutdownAnimation();
powerOffFinalStage(false, false);
#else
updateScreen();
initialEventTime = ev->time;
#endif
}
else
{
#if !defined(HAS_COLOURS)
handleEvent(ev);
#endif
}
return MENU_STATUS_SUCCESS;
}
#if defined(HAS_COLOURS)
static void crtShutdownAnimation(void)
{
const int W = DISPLAY_SIZE_X;
const int H = DISPLAY_SIZE_Y;
const int cx = W / 2;
const int cy = H / 2;
const int VSTEPS = 12;
const int HSTEPS = 12;
const uint16_t white = 0xFFFF;
displayRestorePrimaryScreenBuffer();
uint16_t *fb = displayGetPrimaryScreenBuffer();
uint16_t *snap = uiNotificationGetOffscreenBuffer();
// snapshot the screen as it is right now
memcpy(snap, fb, W * H * sizeof(uint16_t));
// Phase 1: squeeze the whole image vertically into a thin central band.
for (int step = 1; step <= VSTEPS; step++)
{
int bandH = H - ((H - 2) * step) / VSTEPS; // 128 -> 2
int top = cy - (bandH / 2);
memset(fb, 0, W * H * sizeof(uint16_t));
for (int dy = 0; dy < bandH; dy++)
{
int srcY = (dy * H) / bandH;
if (srcY >= H)
{
srcY = H - 1;
}
memcpy(&fb[(top + dy) * W], &snap[srcY * W], W * sizeof(uint16_t));
}
displayRender();
vTaskDelay((15 / portTICK_PERIOD_MS));
}
// Phase 2: the band becomes a bright line that collapses horizontally.
for (int step = 1; step <= HSTEPS; step++)
{
int lineW = W - ((W - 2) * step) / HSTEPS; // 160 -> 2
int left = cx - (lineW / 2);
memset(fb, 0, W * H * sizeof(uint16_t));
for (int dx = 0; dx < lineW; dx++)
{
fb[((cy - 1) * W) + (left + dx)] = white;
fb[(cy * W) + (left + dx)] = white;
}
displayRender();
vTaskDelay((15 / portTICK_PERIOD_MS));
}
// Phase 3: a brief central dot, then black.
memset(fb, 0, W * H * sizeof(uint16_t));
fb[((cy - 1) * W) + (cx - 1)] = white;
fb[((cy - 1) * W) + cx] = white;
fb[(cy * W) + (cx - 1)] = white;
fb[(cy * W) + cx] = white;
displayRender();
vTaskDelay((70 / portTICK_PERIOD_MS));
memset(fb, 0, W * H * sizeof(uint16_t));
displayRender();
}
#endif
#if !defined(HAS_COLOURS)
static void updateScreen(void)
{
#if defined(HAS_COLOURS)
bool dblHeight = settingsIsOptionBitSet(BIT_UI_USES_DOUBLE_HEIGHT);
#else
const bool dblHeight = false;
#endif
uint16_t fontHeight = (FONT_SIZE_3_HEIGHT * (dblHeight ? 2 : 1));
displayClearBuf();
@ -104,3 +191,4 @@ static void handleEvent(uiEvent_t *ev)
}
}
#endif

View file

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

View file

@ -1853,12 +1853,12 @@ void uiUtilityRenderQSOData(void)
displayThemeResetToDefault();
}
// Toggle the audio mute (speaker) state. The header shows a mute icon and the LED
// flashes amber (see the main loop) while muted.
// Toggle the audio mute (speaker) state. The flashing red LED (see the main
// loop) indicates the muted state.
void toggleAudioMute(void)
{
audioAmpMute(!audioAmpIsMuted());
headerRowIsDirty = true; // refresh the header so the mute icon appears/clears
headerRowIsDirty = true;
}
void uiUtilityRenderHeader(bool isVFODualWatchScanning, bool isVFOSweepScanning, bool forceBatteryDisplay)
@ -1878,8 +1878,6 @@ void uiUtilityRenderHeader(bool isVFODualWatchScanning, bool isVFOSweepScanning,
static uint32_t blinkTime = 0;
int radioMode = trxGetMode();
uint8_t powerLevel = trxGetPowerLevel();
// Muted either explicitly (SK2) or by the volume knob turned fully down (-99).
bool audioIsMuted = (audioAmpIsMuted() || (lastVolume == -99));
bool isPerChannelPower = (currentChannelData->libreDMR_Power != 0x00);
bool scanIsActive = (uiDataGlobal.Scan.active || uiDataGlobal.Scan.toneActive);
bool batteryIsLow = batteryIsLowWarning();
@ -2000,7 +1998,7 @@ void uiUtilityRenderHeader(bool isVFODualWatchScanning, bool isVFOSweepScanning,
displayPrintCore(MODE_TEXT_X_OFFSET, DISPLAY_Y_POS_HEADER, buffer,
(((nonVolatileSettings.hotspotType != HOTSPOT_TYPE_OFF) && (uiDataGlobal.dmrDisabled == false)) ? FONT_SIZE_1_BOLD : FONT_SIZE_1), TEXT_ALIGN_LEFT, isInverted);
// (mute is shown by the header "M" indicator, not a strike-through)
// (mute is indicated by the flashing red LED, not a strike-through)
}
if ((aprsIsTransmittingOnSameQRG == false) && (monitorModeData.isEnabled == false) && (isVFOSweepScanning == false) && (isVFODualWatchScanning == false) &&
@ -2068,7 +2066,7 @@ void uiUtilityRenderHeader(bool isVFODualWatchScanning, bool isVFOSweepScanning,
displayPrintCore(MODE_TEXT_X_OFFSET, DISPLAY_Y_POS_HEADER, (isVFODualWatchScanning ? "[DW]" : "DMR"), ((nonVolatileSettings.hotspotType != HOTSPOT_TYPE_OFF) ? FONT_SIZE_1_BOLD : FONT_SIZE_1), TEXT_ALIGN_LEFT, isInverted);
// (mute is shown by the header "M" indicator, not a strike-through)
// (mute is indicated by the flashing red LED, not a strike-through)
}
if (isVFODualWatchScanning == false)
@ -2180,13 +2178,8 @@ void uiUtilityRenderHeader(bool isVFODualWatchScanning, bool isVFOSweepScanning,
bool displayTime = (settingsIsOptionBitSet(BIT_DISPLAY_TIME_IN_HEADER) && (forceBatteryDisplay == false) && (batteryIsLow == false));
#endif
#endif
// Mute indicator: bold "M" just left of the battery indicator.
if (audioIsMuted)
{
displayThemeApply(THEME_ITEM_FG_HEADER_TEXT, THEME_ITEM_BG_HEADER_TEXT);
displayPrintCore((DISPLAY_SIZE_X - 40), DISPLAY_Y_POS_HEADER, "M", FONT_SIZE_1_BOLD, TEXT_ALIGN_LEFT, false);
displayThemeResetToDefault();
}
// Mute state is indicated by the flashing red LED only; no header icon
// (it collided with the GPS text in the status bar).
// Display battery percentage/voltage
bool apoEnabled = (nonVolatileSettings.apo > 0);

View file

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

View file

@ -6,14 +6,24 @@ chipset — the **TYT MD-UV380 / Retevis RT-3S** and **Baofeng DM-1701 / Retevis
RT-84**.
It adds a number of UI and quality-of-life changes on top of OpenGD77 (segmented
S-meter, 7-segment frequency font, screen dimming, an audio-mute function, menu
icons, and more). See [`CHANGELOG.md`](CHANGELOG.md) for the full list.
S-meter, 7-segment frequency font, screen dimming, an audio-mute function,
DMR text messaging, colour theme presets, on-radio channel editing, and more).
📖 **[`MANUAL.md`](MANUAL.md)** is the full feature & usage guide — every feature is
tagged 🟢 **[FreeTRX]** (new/changed here) or ⚪ **[OpenGD77]** (inherited). See
[`CHANGELOG.md`](CHANGELOG.md) for the version history.
## Screenshots
| Segmented S-meter & DMR call | 7-segment frequency display |
| Segmented S-meter & DMR call | 7-segment frequency display | Main menu |
| --- | --- | --- |
| ![DMR channel showing the talkgroup, repeater name and the block S-meter](docs/images/screenshot-dmr.png) | ![VFO with the 7-segment frequency font](docs/images/screenshot-vfo.png) | ![Main menu with the Messages entry highlighted](docs/images/screenshot-menu.png) |
| DMR text messages | Reading a received message |
| --- | --- |
| ![DMR call with the block S-meter](docs/images/screenshot-dmr.png) | ![VFO with the 7-segment frequency font](docs/images/screenshot-vfo.png) |
| ![Messages menu: quick text, options, new message, inbox and sent](docs/images/screenshot-messages.png) | ![A received weather-report message from a BrandMeister service](docs/images/screenshot-message-view.png) |
*Screens shown with the **Neon 80s** theme.*
## What's different from OpenGD77
@ -21,23 +31,52 @@ FreeTRX keeps OpenGD77's core (DMR/FM, hotspot, CPS compatibility) and adds:
- **Audio mute — new SK2 short-tap.** A short tap of **SK2** on the VFO/Channel
screen toggles the speaker mute on and off. Turning the **volume knob fully
down** counts as muted too. While muted, a bold **"M"** appears in the status
bar (left of the battery) and the **red LED flashes** (0.5 s every 2 s). SK2's
down** counts as muted too. While muted, the **red LED flashes** (0.5 s every
2 s). SK2's
existing roles are unchanged: held as a modifier, extra-long-press for
monitor/squelch-off, and — while a DMR QSO is on screen — a tap still sets the
TX talkgroup to the incoming one.
- **Segmented block S-meter** with one block per S-level (S1S9 green, S9+ red)
and a scale label row (S1 S3 S5 S7 S9 / +20 +40 +60).
- **7-segment "LED" frequency font** with faintly-lit inactive segments, on the
VFO and the SK1-held frequency view.
- **Screen dimming** — optionally dim the backlight to 100 / 50 / 30 % after
- **7-segment "LED" frequency font** on the VFO and the SK1-held frequency view.
- **Screen dimming** — optionally dim the backlight to 100 / 50 / 30 / 15 % after
inactivity (Auto backlight mode); new setting in Display Options.
- **Main-menu icons** next to each top-level entry.
- **DMR text messages (SMS)** — send and receive text messages on the radio,
compatible with BrandMeister, hotspots and OEM radios. Compose on the keypad
(or pick the recipient from your contacts), inbox/sent boxes, quick-text
templates, delivery confirmation and an incoming-message popup (see the manual
for details).
- **Theme presets** — pick a colour theme on the radio: **Custom** (CPS-editable,
default), **BASIC 80s** (Commodore 64), **Terminal** (green-on-black),
**Vertexic** (amber/orange), **Red**, **Neon 80s** (multi-colour synthwave),
or **Pride** (rainbow background). CPS edits only affect Custom.
- **On-radio channel management** — create and delete channels directly from the
Channel quick menu, no PC/CPS required (see [Managing channels](#managing-channels-on-the-radio)).
- **CRT-style shut-off animation** — the current screen collapses to a line and
then a dot, like an old CRT switching off (replaces the "Power Off" message).
- **DMR reliability fix** — resolves a hard-fault (freeze needing a battery pull)
on the DMR path that affected recent self-built OpenGD77 revisions.
See [`CHANGELOG.md`](CHANGELOG.md) for the complete list.
## Managing channels on the radio
FreeTRX can create and delete channels without a PC. Both actions live in the
**Channel quick menu** — from the Channel screen, press the **top (orange)
button** to open it, then scroll to the bottom:
- **New channel** — creates a blank **145.500 MHz FM** channel in the first free
slot, adds it to the current zone, and opens **Channel Details** so you can set
the name, frequency, tones, and so on. Press the **green** key to save. The new
channel is immediately selectable in the zone (no need to re-enter it).
- **Delete Channel** — deletes the currently-selected channel after a
confirmation (**green** confirms, **red** cancels). It is also scrubbed from
every zone that referenced it. The last remaining channel cannot be deleted,
and channels locked read-only (e.g. APRS) are protected.
> **Back up your codeplug** (via the CPS) before making bulk edits — these changes
> are written straight to the radio and there is no on-device undo.
## Supported radios
This first iteration is developed for and **tested only on the TYT MD-UV380 and

View file

@ -34,6 +34,10 @@ Output: `MDUV380_firmware/build/Open<platform>.bin` (the flashable, FM-only imag
codec is merged in at flash time — see below). The build is always clean (`rm -rf build`),
so building one platform overwrites the other's output.
Each build finishes with a **memory-usage summary** — used, size and free bytes for the
FLASH (code), RAM and CCMRAM regions — so the remaining headroom for new features is
visible at a glance.
---
## 3. Flashing
@ -87,9 +91,19 @@ Models: `MD-UV380`, `DM-1701`, `MD-9600`, `MD-2017`, `MD-380`.
grabber (the "gd-77" name is historical). It talks over the USB **CDC serial port**,
auto-detects the radio model and reads the MD-UV380's 160×128 RGB565 framebuffer into a PNG.
1. Power the radio on **normally** (not DFU mode) and navigate to the screen you want.
Connect USB.
2. Run it (`-o` takes the filename **without** `.png`):
Power the radio on **normally** (not DFU mode), navigate to the screen you want, and
connect USB.
**Easiest — the `./screenshot.sh` helper** (auto-detects the serial port and sets up a
self-contained Python venv with `pyserial` + `pillow` on first run):
```bash
./screenshot.sh # -> screenshots/screenshot-<timestamp>.png
./screenshot.sh vfo-7seg # -> screenshots/vfo-7seg.png
./screenshot.sh -d /dev/cu.usbmodemXXXX [name] # explicit port
```
**Or call the grabber directly** (`-o` takes the filename **without** `.png`):
```bash
python3 MDUV380_firmware/tools/gd-77_screen_grabber.py \

View file

@ -22,14 +22,38 @@ echo "==> Building toolchain image ($IMAGE)"
docker build --platform linux/amd64 -t "$IMAGE" "$REPO_DIR/docker"
echo "==> Running build in container ($PLATFORM)"
BUILD_LOG="$(mktemp)"
trap 'rm -f "$BUILD_LOG"' EXIT
# Stream to the terminal and capture a copy so we can re-show the linker's
# memory-usage table at the end (pipefail makes a build failure still abort).
docker run --rm --platform linux/amd64 \
-v "$REPO_DIR":/work \
"$IMAGE" \
bash /work/docker/build.sh "$PLATFORM"
bash /work/docker/build.sh "$PLATFORM" 2>&1 | tee "$BUILD_LOG"
echo
echo "==> Firmware ready:"
echo " $REPO_DIR/MDUV380_firmware/build/${OUT}.bin"
# Re-print the linker's per-region memory usage (emitted by --print-memory-usage
# during the link) with the free bytes worked out for each region.
if grep -q "Memory region" "$BUILD_LOG"; then
echo
echo "==> Memory usage (FLASH = code budget, RAM/CCMRAM = data budget):"
grep -A3 "Memory region" "$BUILD_LOG" | awk '
function toBytes(n, unit) {
if (unit == "KB") return n * 1024
if (unit == "MB") return n * 1024 * 1024
if (unit == "GB") return n * 1024 * 1024 * 1024
return n # already bytes
}
NR == 1 { printf " %-9s %11s %12s %8s %11s\n", "Region", "Used", "Size", "Used%", "Free"; next }
/:/ {
gsub(":", "", $1)
used = toBytes($2, $3); size = toBytes($4, $5); free = size - used
printf " %-9s %9d B %10d B %7s %9d B\n", $1, used, size, $6, free
}'
fi
echo
echo "Flash it (with the radio in firmware-update mode) using:"
echo " ./MDUV380_firmware/tools/opengd77_stm32_firmware_loader.py \\"

View file

@ -63,6 +63,7 @@ ASFLAGS := $(CPU) -x assembler-with-cpp -DDEBUG -g3 -MMD -MP
LDSCRIPT := ../STM32F405VGTX_FLASH.ld
LDFLAGS := $(CPU) -T$(LDSCRIPT) --specs=nano.specs \
-Wl,-Map=$(TARGET).map,--cref -Wl,--gc-sections -static \
-Wl,--print-memory-usage \
-Wl,--start-group -lc -lm -Wl,--end-group
# ---- Sources --------------------------------------------------------------

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -58,11 +58,11 @@ done
# ---- resolve / build the language file ------------------------------------
LANG_ARGS=()
if [[ -n "$LANGUAGE" && "$(lc "$LANGUAGE")" != "none" ]]; then
# Build the .gla files once if they're missing.
if ! ls "$LANG_SRC"/*.gla >/dev/null 2>&1; then
# Always (re)build the .gla language packs so edits to the *.h language files
# are picked up. (They're built from the .h sources by languages_builder; a
# stale pack would otherwise show blank/old strings on the radio.)
echo "==> Building language (.gla) files..."
( cd "$LANG_SRC" && gcc -w -I../ -o languages_builder languages_builder.c && ./languages_builder >/dev/null )
fi
# Case-insensitive match of the requested name to an actual <Name>.gla file.
GLA=""
for f in "$LANG_SRC"/*.gla; do

55
screenshot.sh Normal file
View file

@ -0,0 +1,55 @@
#!/usr/bin/env bash
#
# Grab a screenshot from the radio over USB (CDC serial), using the OpenGD77
# screen grabber. Runs natively (USB serial is not available inside Docker) and
# sets up a self-contained Python venv (pyserial + pillow) on first use.
#
# The radio must be powered on, running the firmware, and connected via USB.
#
# Usage:
# ./screenshot.sh # auto-detect port -> screenshots/screenshot-<timestamp>.png
# ./screenshot.sh vfo-7seg # -> screenshots/vfo-7seg.png
# ./screenshot.sh -d /dev/cu.usbmodem1234 [name] # explicit serial port
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
GRABBER="$REPO_DIR/MDUV380_firmware/tools/gd-77_screen_grabber.py"
VENV="$REPO_DIR/MDUV380_firmware/tools/.venv"
OUT_DIR="$REPO_DIR/screenshots"
# ---- optional explicit device (-d /dev/...) -------------------------------
DEVICE=""
if [[ "${1:-}" == "-d" ]]; then
DEVICE="${2:-}"; shift 2 || true
fi
# ---- auto-detect the radio's serial port ----------------------------------
if [[ -z "$DEVICE" ]]; then
case "$(uname -s)" in
Darwin) DEVICE="$(ls /dev/cu.usbmodem* 2>/dev/null | head -1 || true)" ;;
*) DEVICE="$(ls /dev/ttyACM* 2>/dev/null | head -1 || true)" ;;
esac
fi
if [[ -z "$DEVICE" ]]; then
echo "!! No radio serial port found." >&2
echo " Connect the radio via USB (powered on, running firmware) and retry, or pass one:" >&2
echo " ./screenshot.sh -d /dev/cu.usbmodemXXXX" >&2
exit 1
fi
# ---- one-time Python venv with the grabber's dependencies ------------------
if [[ ! -x "$VENV/bin/python" ]]; then
echo "==> First run: creating Python venv with pyserial + pillow..."
python3 -m venv "$VENV"
"$VENV/bin/pip" install --quiet --upgrade pip
"$VENV/bin/pip" install --quiet pyserial pillow
fi
# ---- output name -----------------------------------------------------------
mkdir -p "$OUT_DIR"
NAME="${1:-screenshot-$(date +%Y%m%d-%H%M%S)}"
OUT="$OUT_DIR/$NAME"
echo "==> Grabbing from $DEVICE"
"$VENV/bin/python" "$GRABBER" -d "$DEVICE" -o "$OUT"
echo "==> Saved: $OUT.png"

View file

@ -53,12 +53,18 @@ try:
d=json.load(sys.stdin); print(d.get("id","") if isinstance(d,dict) else "")
except Exception:
print("")')"
# Everything before v1.0.0 is flagged as a pre-release.
if [ "${VER%%.*}" = "0" ]; then PRERELEASE=true; else PRERELEASE=false; fi
if [ -z "$ID" ]; then
BODY="$(python3 -c 'import json,sys; print(json.dumps({"tag_name":sys.argv[1],"name":"FreeTRX "+sys.argv[1],"body":sys.argv[2],"draft":False,"prerelease":False}))' "$TAG" "$NOTES")"
BODY="$(python3 -c 'import json,sys; print(json.dumps({"tag_name":sys.argv[1],"name":"FreeTRX "+sys.argv[1],"body":sys.argv[2],"draft":False,"prerelease":(sys.argv[3]=="true")}))' "$TAG" "$NOTES" "$PRERELEASE")"
ID="$(curl -fsS -X POST "$API/releases" -H "Authorization: token $FORGEJO_TOKEN" -H "Content-Type: application/json" -d "$BODY" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')"
echo "==> Created release $TAG (id=$ID)"
echo "==> Created release $TAG (id=$ID, prerelease=$PRERELEASE)"
else
echo "==> Reusing existing release $TAG (id=$ID)"
# Keep the pre-release flag correct on re-runs.
curl -fsS -X PATCH "$API/releases/$ID" -H "Authorization: token $FORGEJO_TOKEN" -H "Content-Type: application/json" \
-d "$(python3 -c 'import json,sys; print(json.dumps({"prerelease":(sys.argv[1]=="true")}))' "$PRERELEASE")" >/dev/null
echo "==> Reusing existing release $TAG (id=$ID, prerelease=$PRERELEASE)"
fi
# Attach each staged binary, replacing an existing asset of the same name.

49
website/build-docs.sh Normal file
View file

@ -0,0 +1,49 @@
#!/usr/bin/env bash
#
# Build the FreeTRX documentation website (MkDocs Material) entirely in Docker.
# Source markdown lives in the repo root; this assembles it into _build/docs and
# renders a static, self-contained HTML site with client-side search into
# website/_build/site.
#
# Usage: ./website/build-docs.sh # build
# ./website/build-docs.sh serve # live-reload preview on http://localhost:8000
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$HERE/.." && pwd)"
SRC="$HERE/_build/docs"
IMAGE="squidfunk/mkdocs-material:9"
# ---- assemble the docs source tree from the root markdown -----------------
rm -rf "$HERE/_build"
mkdir -p "$SRC/images"
cp "$HERE/home.md" "$SRC/index.md"
cp "$ROOT/MANUAL.md" "$SRC/MANUAL.md"
cp "$ROOT/TOOLS.md" "$SRC/TOOLS.md"
cp "$ROOT/CHANGELOG.md" "$SRC/CHANGELOG.md"
cp "$ROOT"/docs/images/*.png "$SRC/images/" 2>/dev/null || true
# MANUAL.md links back to README.md (a repo file, not a site page) -> point it at
# the site home instead.
sed -i.bak 's#(README.md)#(index.md)#g' "$SRC/MANUAL.md" && rm -f "$SRC/MANUAL.md.bak"
# Optional public URL (sitemap / canonical only). Set SITE_URL to override the
# placeholder in mkdocs.yml — the site works at any URL regardless.
ENVARGS=()
if [[ -n "${SITE_URL:-}" ]]; then
echo "==> Site URL: $SITE_URL"
ENVARGS=(-e "SITE_URL=$SITE_URL")
fi
# ---- build (or serve) -----------------------------------------------------
if [[ "${1:-}" == "serve" ]]; then
echo "==> Serving docs on http://localhost:8000 (Ctrl-C to stop)"
docker run --rm -it -p 8000:8000 "${ENVARGS[@]+"${ENVARGS[@]}"}" -v "$HERE":/docs "$IMAGE" serve -a 0.0.0.0:8000
else
echo "==> Building docs site"
docker run --rm "${ENVARGS[@]+"${ENVARGS[@]}"}" -v "$HERE":/docs "$IMAGE" build
echo
echo "==> Done. Static site (upload the *contents* of this folder):"
echo " $HERE/_build/site"
fi

36
website/home.md Normal file
View file

@ -0,0 +1,36 @@
# FreeTRX
**FreeTRX** is a fork of the [OpenGD77](https://opengd77.org) firmware for the
**TYT MD-UV380 / Retevis RT-3S** and **Baofeng DM-1701 / Retevis RT-84**.
[Latest release :material-download:](https://git.dk1da.de/DK1DA/FreeTRX/releases){ .md-button .md-button--primary }
[Source :material-git:](https://git.dk1da.de/DK1DA/FreeTRX){ .md-button }
## Screenshots
<div class="grid cards" markdown>
![DMR call with the segmented S-meter](images/screenshot-dmr.png)
![VFO with the 7-segment frequency font](images/screenshot-vfo.png)
</div>
## Documentation
- **[User Manual](MANUAL.md)** — every feature, tagged 🟢 **FreeTRX** (new/changed)
or ⚪ **OpenGD77** (inherited), with a button reference.
- **[Building & Flashing](TOOLS.md)** — Docker build (`./build-firmware.sh`),
flashing (`./flash.sh`), and grabbing screenshots (`./screenshot.sh`).
- **[Changelog](CHANGELOG.md)** — version history.
## Supported radios
Developed and tested on the **TYT MD-UV380 / Retevis RT-3S**. A DM-1701 / RT-84
build is also produced but not yet officially tested. Want to help test another
OpenGD77-supported radio? Email **Marcus, DK1DA** at
[qsl@dk1da.de](mailto:qsl@dk1da.de).
!!! note "License"
FreeTRX inherits the OpenGD77 license (modified BSD 3-Clause with a
non-commercial clause). It is not GPL-compatible and cannot be relicensed.

63
website/mkdocs.yml Normal file
View file

@ -0,0 +1,63 @@
site_name: FreeTRX Documentation
site_description: User manual for the FreeTRX firmware (an OpenGD77 fork for MD-UV380 / DM-1701)
site_author: Marcus, DK1DA
# Public URL where the docs are hosted. Only affects the sitemap / canonical links /
# search-share URLs — the site itself works at ANY domain or path (relative links).
# Override at build time without editing this file: SITE_URL=https://your.url/ ./build-docs.sh
site_url: !ENV [SITE_URL, "https://docs.example.com/"]
repo_name: DK1DA/FreeTRX
repo_url: https://git.dk1da.de/DK1DA/FreeTRX
# Source markdown is assembled into _build/docs by build-docs.sh; output goes to _build/site.
docs_dir: _build/docs
site_dir: _build/site
use_directory_urls: true
theme:
name: material
language: en
palette:
- media: "(prefers-color-scheme: light)"
scheme: default
primary: green
accent: green
toggle:
icon: material/weather-night
name: Switch to dark mode
- media: "(prefers-color-scheme: dark)"
scheme: slate
primary: green
accent: green
toggle:
icon: material/weather-sunny
name: Switch to light mode
features:
- navigation.top
- navigation.sections
- navigation.instant
- search.suggest
- search.highlight
- search.share
- content.code.copy
- toc.follow
plugins:
- search # built-in client-side (lunr.js) full-text search — no external service
nav:
- Home: index.md
- User Manual: MANUAL.md
- Building & Flashing: TOOLS.md
- Changelog: CHANGELOG.md
markdown_extensions:
- admonition
- attr_list
- tables
- toc:
permalink: true
- pymdownx.superfences
- pymdownx.highlight
- pymdownx.emoji:
emoji_index: !!python/name:material.extensions.emoji.twemoji
emoji_generator: !!python/name:material.extensions.emoji.to_svg