Added tetra rx ext app (#3257)

This commit is contained in:
Totoo
2026-07-09 09:10:08 +02:00
committed by GitHub
parent 42122a739c
commit d671455f79
22 changed files with 1402 additions and 0 deletions
+12
View File
@@ -380,6 +380,17 @@ set(EXTCPPSRC
#signal_hunter
external/signal_hunter/main.cpp
external/signal_hunter/ui_signal_hunter.cpp
#tetra rx
external/tetra_rx/main.cpp
external/tetra_rx/ui_tetra_rx.cpp
external/tetra_rx/tetra_crc.cpp
external/tetra_rx/tetra_descrambler.cpp
external/tetra_rx/tetra_interleave.cpp
external/tetra_rx/tetra_rcpc.cpp
external/tetra_rx/tetra_viterbi.cpp
)
set(EXTAPPLIST
@@ -473,6 +484,7 @@ set(EXTAPPLIST
hard_reset
secplustx
signal_hunter
tetra_rx
)
# sdusb has type conflicts with PRALINE (HackRF Pro) - add only for non-PRALINE builds
+10
View File
@@ -114,6 +114,7 @@ MEMORY
ram_external_app_vor_rx (rwx) : org = 0xAE090000, len = 32k
ram_external_app_vor_tx (rwx) : org = 0xAE0A0000, len = 32k
ram_external_app_signal_hunter (rwx) : org = 0xAE0B0000, len = 32k
ram_external_app_tetra_rx (rwx) : org = 0xAE0C0000, len = 32k
}
SECTIONS
@@ -663,4 +664,13 @@ SECTIONS
KEEP(*(.external_app.app_signal_hunter.application_information));
*(*ui*external_app*signal_hunter*);
} > ram_external_app_signal_hunter
.external_app_tetra_rx : ALIGN(4) SUBALIGN(4)
{
KEEP(*(.external_app.app_tetra_rx.application_information));
*(*ui*external_app*tetra_rx*);
} > ram_external_app_tetra_rx
}
+81
View File
@@ -0,0 +1,81 @@
/*
* Copyright (C) 2026 HTotoo
*
* This file is part of PortaPack.
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2, or (at your option)
* any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; see the file COPYING. If not, write to
* the Free Software Foundation, Inc., 51 Franklin Street,
* Boston, MA 02110-1301, USA.
*/
#include "ui_tetra_rx.hpp"
#include "ui_navigation.hpp"
#include "external_app.hpp"
namespace ui::external_app::tetra_rx {
void initialize_app(ui::NavigationView& nav) {
nav.push<TetraRxView>();
}
} // namespace ui::external_app::tetra_rx
extern "C" {
__attribute__((section(".external_app.app_tetra_rx.application_information"), used)) application_information_t _application_information_tetra_rx = {
/*.memory_location = */ (uint8_t*)0x00000000,
/*.externalAppEntry = */ ui::external_app::tetra_rx::initialize_app,
/*.header_version = */ CURRENT_HEADER_VERSION,
/*.app_version = */ VERSION_MD5,
/*.app_name = */ "Tetra",
/*.bitmap_data = */ {
0xE0,
0x0F,
0x18,
0x38,
0xE4,
0x67,
0x7E,
0xCE,
0xC7,
0xCC,
0x00,
0x00,
0xFF,
0x4F,
0xBA,
0xB2,
0x9A,
0xEE,
0xBA,
0xB2,
0x00,
0x00,
0x3B,
0xE3,
0x73,
0x7E,
0xC6,
0x27,
0x1C,
0x18,
0xF0,
0x07,
},
/*.icon_color = */ ui::Color::orange().v,
/*.menu_location = */ app_location_t::RX,
/*.desired_menu_position = */ -1,
/*.m4_app_tag = portapack::spi_flash::image_tag_tetrarx*/ {'P', 'T', 'E', 'T'},
/*.m4_app_offset = */ 0x00000000, // will be filled at compile time
};
}
+45
View File
@@ -0,0 +1,45 @@
#pragma once
#include <stdint.h>
#include <stddef.h>
namespace ui::external_app::tetra_rx {
class BitVector {
public:
BitVector(uint8_t* p, size_t bits)
: data_(p),
bits_(bits) {
}
inline bool get(size_t bit) const {
return (data_[bit >> 3] >>
(7 - (bit & 7))) &
1;
}
inline void set(size_t bit, bool value) {
if (value)
data_[bit >> 3] |=
1 << (7 - (bit & 7));
else
data_[bit >> 3] &=
~(1 << (7 - (bit & 7)));
}
inline void xor_bit(size_t bit, bool value) {
if (value)
data_[bit >> 3] ^=
1 << (7 - (bit & 7));
}
inline size_t size() const {
return bits_;
}
private:
uint8_t* data_;
size_t bits_;
};
} // namespace ui::external_app::tetra_rx
+33
View File
@@ -0,0 +1,33 @@
#include "tetra_crc.hpp"
namespace ui::external_app::tetra_rx {
uint16_t CRC::crc16_itut_bits(
const uint8_t* bits,
uint32_t n_bits,
uint16_t init) {
uint16_t crc = init;
for (uint32_t i = 0; i < n_bits; i++) {
uint8_t bit =
(bits[i >> 3] >>
(7 - (i & 7))) &
1;
crc ^= uint16_t(bit) << 15;
if (crc & 0x8000) {
crc =
(crc << 1) ^
0x1021;
} else {
crc <<= 1;
}
crc &= 0xffff;
}
return crc;
}
} // namespace ui::external_app::tetra_rx
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include <stdint.h>
namespace ui::external_app::tetra_rx {
class CRC {
public:
static constexpr uint16_t CRC_OK = 0x1d0f;
static uint16_t crc16_itut_bits(
const uint8_t* bits,
uint32_t n_bits,
uint16_t init = 0xffff);
};
} // namespace ui::external_app::tetra_rx
@@ -0,0 +1,60 @@
#include "tetra_descrambler.hpp"
namespace ui::external_app::tetra_rx {
static inline uint8_t lfsr_step(uint32_t& s) {
uint8_t b = 0;
b ^= (s >> (32 - 32)) & 1;
b ^= (s >> (32 - 26)) & 1;
b ^= (s >> (32 - 23)) & 1;
b ^= (s >> (32 - 22)) & 1;
b ^= (s >> (32 - 16)) & 1;
b ^= (s >> (32 - 12)) & 1;
b ^= (s >> (32 - 11)) & 1;
b ^= (s >> (32 - 10)) & 1;
b ^= (s >> (32 - 8)) & 1;
b ^= (s >> (32 - 7)) & 1;
b ^= (s >> (32 - 5)) & 1;
b ^= (s >> (32 - 4)) & 1;
b ^= (s >> (32 - 2)) & 1;
b ^= (s >> (32 - 1)) & 1;
s = (s >> 1) | (uint32_t(b) << 31);
return b;
}
void Descrambler::generate(
uint32_t init,
uint8_t* seq,
size_t bits) {
uint32_t s = init;
for (size_t i = 0; i < bits; i++)
seq[i] = lfsr_step(s);
}
void Descrambler::descramble(
BitVector& bits,
uint32_t init) {
uint32_t s = init;
for (size_t i = 0; i < bits.size(); i++)
bits.xor_bit(i, lfsr_step(s));
}
uint32_t Descrambler::dmo_scramb_init(
uint32_t mni,
uint32_t src) {
uint32_t init =
(src & 0xFFFFFF) |
((mni & 0x3F) << 24);
init <<= 2;
init |= 3;
return init;
}
} // namespace ui::external_app::tetra_rx
@@ -0,0 +1,26 @@
#pragma once
#include <stdint.h>
#include "tetra_bits.hpp"
namespace ui::external_app::tetra_rx {
class Descrambler {
public:
static constexpr uint32_t SCRAMB_INIT_BSCH = 0x00000003;
static void descramble(
BitVector& bits,
uint32_t init = SCRAMB_INIT_BSCH);
static void generate(
uint32_t init,
uint8_t* sequence,
size_t bits);
static uint32_t dmo_scramb_init(
uint32_t mni,
uint32_t src);
};
} // namespace ui::external_app::tetra_rx
@@ -0,0 +1,21 @@
#include "tetra_interleave.hpp"
namespace ui::external_app::tetra_rx {
void Interleave::block_deinterleave(
BitVector& in,
BitVector& out,
uint32_t K,
uint32_t a) {
for (uint32_t i = 1; i <= K; i++) {
uint32_t k =
1 +
((a * i) % K);
out.set(
i - 1,
in.get(k - 1));
}
}
} // namespace ui::external_app::tetra_rx
@@ -0,0 +1,16 @@
#pragma once
#include "tetra_bits.hpp"
namespace ui::external_app::tetra_rx {
class Interleave {
public:
static void block_deinterleave(
BitVector& in,
BitVector& out,
uint32_t K,
uint32_t a);
};
} // namespace ui::external_app::tetra_rx
+24
View File
@@ -0,0 +1,24 @@
#include "tetra_rcpc.hpp"
#include <string.h>
namespace ui::external_app::tetra_rx {
static constexpr uint8_t P_RATE_2_3[3] = {1, 2, 5};
static constexpr uint32_t T_RATE_2_3 = 3;
static constexpr uint32_t PERIOD = 8;
void RCPC::depuncture_2_3(BitVector& in, uint8_t* out, uint32_t in_bits) {
const uint32_t mother_bits = ((in_bits + T_RATE_2_3 - 1) / T_RATE_2_3) * PERIOD + PERIOD;
memset(out, ERASED, mother_bits);
for (uint32_t j = 1; j <= in_bits; j++) {
uint32_t i = j;
uint32_t k = PERIOD * ((i - 1) / T_RATE_2_3);
k += P_RATE_2_3[(i - 1) % T_RATE_2_3];
if (k >= 1 && k <= mother_bits) {
out[k - 1] = in.get(j - 1);
}
}
}
} // namespace ui::external_app::tetra_rx
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include <stdint.h>
#include "tetra_bits.hpp"
namespace ui::external_app::tetra_rx {
class RCPC {
public:
static constexpr uint8_t ERASED = 0xff;
static void depuncture_2_3(
BitVector& in,
uint8_t* out,
uint32_t in_bits);
};
} // namespace ui::external_app::tetra_rx
@@ -0,0 +1,73 @@
#include "tetra_viterbi.hpp"
#include <string.h>
namespace ui::external_app::tetra_rx {
int Viterbi::decode_cch(
const uint8_t* mother,
uint32_t n_info,
uint8_t* bits,
uint8_t* trace_buffer) { // Puffer by caller
// Store these on stack, to avoid flash space usage
const uint8_t next_state[16][2] = {
{0, 1}, {2, 3}, {4, 5}, {6, 7}, {8, 9}, {10, 11}, {12, 13}, {14, 15}, {0, 1}, {2, 3}, {4, 5}, {6, 7}, {8, 9}, {10, 11}, {12, 13}, {14, 15}};
const uint8_t next_output[16][2] = {
{0, 15}, {11, 4}, {6, 9}, {13, 2}, {5, 10}, {14, 1}, {3, 12}, {8, 7}, {15, 0}, {4, 11}, {9, 6}, {2, 13}, {10, 5}, {1, 14}, {12, 3}, {7, 8}};
const uint16_t INF = 0x3fff;
uint16_t cost[16];
uint16_t new_cost[16];
// Initialization
for (int i = 0; i < 16; i++) cost[i] = INF;
cost[0] = 0;
for (uint32_t step = 0; step < n_info; step++) {
for (int i = 0; i < 16; i++) new_cost[i] = INF;
const uint8_t* rx = &mother[step * 4];
for (int prev = 0; prev < 16; prev++) {
if (cost[prev] >= INF) continue;
for (int bit = 0; bit < 2; bit++) {
const uint8_t pattern = next_output[prev][bit];
const uint8_t next = next_state[prev][bit];
uint16_t d = 0;
for (int b = 0; b < 4; b++) {
if (rx[b] != 0xff && rx[b] != ((pattern >> (3 - b)) & 1)) d++;
}
const uint16_t total = cost[prev] + d;
if (total < new_cost[next]) {
new_cost[next] = total;
trace_buffer[step * 16 + next] = (prev << 1) | bit;
}
}
}
memcpy(cost, new_cost, sizeof(cost));
}
// Traceback
uint8_t best_state = 0;
for (int i = 1; i < 16; i++) {
if (cost[i] < cost[best_state]) best_state = i;
}
uint8_t state = best_state;
memset(bits, 0, (n_info + 7) >> 3);
for (int step = (int)n_info - 1; step >= 0; step--) {
uint8_t packed = trace_buffer[step * 16 + state];
uint8_t prev = (packed >> 1) & 0x0F;
uint8_t bit = packed & 0x01;
if (bit) bits[step >> 3] |= (1 << (7 - (step & 7)));
state = prev;
}
return cost[best_state];
}
} // namespace ui::external_app::tetra_rx
@@ -0,0 +1,17 @@
#pragma once
#include <stdint.h>
#include <string.h>
namespace ui::external_app::tetra_rx {
class Viterbi {
public:
int decode_cch(
const uint8_t* mother,
uint32_t n_info,
uint8_t* bits,
uint8_t* trace_buffer);
};
} // namespace ui::external_app::tetra_rx
+354
View File
@@ -0,0 +1,354 @@
#include "ui_tetra_rx.hpp"
#include "rtc_time.hpp"
#include "baseband_api.hpp"
#include "string_format.hpp"
#include "portapack_persistent_memory.hpp"
#include "file_path.hpp"
#include "tetra_crc.hpp"
#include "tetra_descrambler.hpp"
#include "tetra_interleave.hpp"
#include "tetra_rcpc.hpp"
#include "tetra_viterbi.hpp"
#include <cstring>
using namespace portapack;
using namespace ui;
namespace ui::external_app::tetra_rx {
template <size_t OutBytes>
bool TetraChannelDecoder::decode_block(
const std::array<uint8_t, 63>& burst,
size_t offset,
size_t len_t5,
size_t type1_bits,
size_t type2_bits,
size_t interleave_a,
std::array<uint8_t, OutBytes>& out,
uint16_t& crc,
int& cost,
uint32_t scramb_init) {
std::array<uint8_t, 64> t5{};
std::array<uint8_t, 64> t3{};
std::array<uint8_t, 1200> mother{};
std::array<uint8_t, 64> type2{};
out.fill(0);
for (size_t i = 0; i < len_t5; i++) {
set_bit(t5, i, get_bit(burst, offset + i));
}
BitVector t5_bits{t5.data(), len_t5};
Descrambler::descramble(t5_bits, scramb_init);
BitVector t3_bits{t3.data(), len_t5};
Interleave::block_deinterleave(t5_bits, t3_bits, len_t5, interleave_a);
RCPC::depuncture_2_3(t3_bits, mother.data(), len_t5);
Viterbi viterbi;
cost = viterbi.decode_cch(mother.data(), type2_bits, type2.data(), trace_buffer);
crc = CRC::crc16_itut_bits(type2.data(), type1_bits + 16);
for (size_t i = 0; i < type1_bits; i++) {
set_bit(out, i, (type2[i >> 3] >> (7 - (i & 7))) & 1);
}
return crc == CRC::CRC_OK;
}
TetraChannelDecoder::Result TetraChannelDecoder::decode_dnb(const TetraDnbMessage& message) {
Result result{};
result.inverted = message.inverted;
if (!network_synced)
return result;
std::array<uint8_t, 63> buffer{};
std::copy(message.payload.begin(), message.payload.end(), buffer.begin());
uint32_t tmo_dnb_seed = (((uint32_t)last_mcc << 20) | ((uint32_t)last_mnc << 6) | (uint32_t)last_bcc) << 2 | 3;
// 1. DNB Full slot (SCH/F)
bool ok = decode_block(buffer, 0, SCH_F_LEN_BITS, SCH_F_TYPE1_BITS, SCH_F_TYPE2_BITS,
SCH_F_INTERLEAVE_A, result.payload, result.crc, result.cost, tmo_dnb_seed);
// 2. SCH/HD Block 1
if (!ok) {
ok = decode_block(buffer, 0, BLK2_LEN_BITS, SB2_TYPE1_BITS, SB2_TYPE2_BITS,
SB2_INTERLEAVE_A, result.payload, result.crc, result.cost, tmo_dnb_seed);
}
// 3. SCH/HD Block 2
if (!ok) {
ok = decode_block(buffer, 216, BLK2_LEN_BITS, SB2_TYPE1_BITS, SB2_TYPE2_BITS,
SB2_INTERLEAVE_A, result.payload, result.crc, result.cost, tmo_dnb_seed);
}
if (ok) {
result.type = Result::Type::Dnb;
result.is_ok = true;
parse_mac_pdu(result);
}
return result;
}
TetraChannelDecoder::Result TetraChannelDecoder::decode_burst(const TetraBurstMessage& message) {
Result result{};
result.sync_errors = message.sync_errors;
result.inverted = message.inverted;
// 1. SCH/S
bool ok = decode_block(
message.payload, BLK1_OFFSET_BITS, BLK1_LEN_BITS,
SB1_TYPE1_BITS, SB1_TYPE2_BITS, SB1_INTERLEAVE_A,
result.payload, result.crc, result.cost,
Descrambler::SCRAMB_INIT_BSCH);
if (!ok) return result;
result.type = Result::Type::Sync;
result.is_ok = true;
parse_sync_pdu(result);
uint16_t current_mcc = result.mcc;
uint16_t current_mnc = result.mnc;
uint8_t current_bcc = result.bcc;
uint8_t current_enc = result.encryption;
// 2. SCH/H
uint16_t h_crc = 0;
int h_cost = 0;
ok = decode_block(
message.payload, TMO_BLK2_OFFSET_BITS, BLK2_LEN_BITS,
SB2_TYPE1_BITS, SB2_TYPE2_BITS, SB2_INTERLEAVE_A,
result.payload, h_crc, h_cost,
Descrambler::SCRAMB_INIT_BSCH);
if (ok) {
result.type = Result::Type::SyncFull;
result.crc = h_crc;
result.cost = h_cost;
result.mcc = current_mcc;
result.mnc = current_mnc;
result.bcc = current_bcc;
result.encryption = current_enc;
parse_mac_pdu(result);
} else {
result.encryption = current_enc;
}
return result;
}
uint32_t TetraChannelDecoder::read_bits(const uint8_t* bytes, size_t bit, size_t count) const {
uint32_t value = 0;
for (size_t i = 0; i < count; i++) {
value <<= 1;
value |= (bytes[(bit + i) >> 3] >> (7 - ((bit + i) & 7))) & 1;
}
return value;
}
void TetraChannelDecoder::parse_sync_pdu(Result& result) {
// Sys 4 bit, then BCC
result.bcc = read_bits(result.payload.data(), 4, 6);
result.timeslot = read_bits(result.payload.data(), 10, 2);
result.frame_number = read_bits(result.payload.data(), 12, 5);
result.encryption = read_bits(result.payload.data(), 30, 1);
result.mcc = read_bits(result.payload.data(), 31, 10);
result.mnc = read_bits(result.payload.data(), 41, 14);
last_mcc = result.mcc;
last_mnc = result.mnc;
last_bcc = result.bcc;
network_synced = true;
}
void TetraChannelDecoder::parse_mac_pdu(Result& result) {
result.pdu_type = read_bits(result.payload.data(), 0, 2);
if (result.pdu_type == 0) { // MAC-RESOURCE
result.encryption = read_bits(result.payload.data(), 4, 2);
uint8_t length_ind = read_bits(result.payload.data(), 7, 6);
if (result.encryption == 0 && length_ind > 0 && length_ind < 62) {
size_t offset = 13;
uint8_t addr_type = read_bits(result.payload.data(), offset, 3);
offset += 3;
if (addr_type == 1) {
result.calling_ssi = read_bits(result.payload.data(), offset, 24);
offset += 24;
} else if (addr_type == 2) {
offset += 10;
} else if (addr_type == 3 || addr_type == 4) {
result.calling_ssi = read_bits(result.payload.data(), offset, 24);
offset += 24;
} else if (addr_type == 5) {
offset += 34;
} else if (addr_type == 6) {
offset += 30;
} else if (addr_type == 7) {
offset += 34;
}
// skip optional fields
if (read_bits(result.payload.data(), offset++, 1)) offset += 4; // Power control
if (read_bits(result.payload.data(), offset++, 1)) offset += 8; // Slot grant
uint8_t chan_alloc = read_bits(result.payload.data(), offset++, 1);
if (chan_alloc == 0) {
uint8_t llc_type = read_bits(result.payload.data(), offset, 4);
offset += 4;
// LLC header skipped
if (llc_type == 0 || llc_type == 1)
offset += 2;
else if (llc_type == 2 || llc_type == 3 || llc_type == 6 || llc_type == 7)
offset += 1;
uint8_t mle_type = read_bits(result.payload.data(), offset, 3);
offset += 3;
// 1 = CMCE
if (mle_type == 1) {
result.cmce_type = read_bits(result.payload.data(), offset, 5);
offset += 5;
result.call_id = read_bits(result.payload.data(), offset, 14);
}
}
}
} else if (result.pdu_type == 2) { // SYSINFO/BCAST
uint8_t bcast_type = read_bits(result.payload.data(), 2, 2);
if (bcast_type == 0) {
result.la = read_bits(result.payload.data(), 82, 14);
result.encryption = read_bits(result.payload.data(), 122, 1);
}
}
}
TetraRxView::TetraRxView(NavigationView& nav)
: nav_{nav} {
baseband::run_prepared_image(portapack::memory::map::m4_code.base());
add_children({&rssi, &channel, &field_rf_amp, &field_lna, &field_vga,
&field_frequency, &text_mcc, &text_la, &text_pdu,
&text_mnc, &text_ts, &text_fn, &text_bcc, &text_enc, &text_debug, &console});
field_frequency.set_step(25000);
receiver_model.set_modulation(ReceiverModel::Mode::NarrowbandFMAudio);
receiver_model.set_sampling_rate(3072000);
receiver_model.set_baseband_bandwidth(1750000);
receiver_model.set_squelch_level(0);
receiver_model.enable();
}
void TetraRxView::focus() {
field_frequency.focus();
}
void TetraRxView::on_data_tetra(const TetraBurstMessage& message) {
packet_count++;
auto result = decoder.decode_burst(message);
if (result.type == TetraChannelDecoder::Result::Type::Sync ||
result.type == TetraChannelDecoder::Result::Type::SyncFull) {
valid_count++;
text_mcc.set("MCC: " + to_string_dec_uint(result.mcc));
text_mnc.set("MNC: " + to_string_dec_uint(result.mnc));
text_bcc.set("BCC: " + to_string_dec_uint(result.bcc));
text_ts.set("TS: " + to_string_dec_uint(result.timeslot));
text_fn.set("FN: " + to_string_dec_uint(result.frame_number));
std::string enc_str = (result.encryption != 0) ? "Encrypted" : "Clear";
text_enc.set("ENC: " + enc_str);
if (result.type == TetraChannelDecoder::Result::Type::SyncFull) {
h_valid_count++;
if (result.la != 0xffff) text_la.set("LA: " + to_string_dec_uint(result.la));
text_pdu.set("PDU: " + decoder.get_pdu_name(result.pdu_type));
}
}
text_debug.set(
"Syn: " + to_string_dec_uint(packet_count) +
", V: " + to_string_dec_uint(valid_count) +
", H: " + to_string_dec_uint(h_valid_count) +
", E:" + to_string_dec_uint(result.sync_errors));
}
void TetraRxView::on_data_dnb(const TetraDnbMessage& message) {
dnb_count++;
auto result = decoder.decode_dnb(message);
if (result.is_ok && result.type == TetraChannelDecoder::Result::Type::Dnb) {
if (result.la != 0xffff) text_la.set("LA: " + to_string_dec_uint(result.la));
text_pdu.set("PDU: " + decoder.get_pdu_name(result.pdu_type));
std::string enc_str = (result.encryption != 0) ? "Encrypted" : "Clear";
text_enc.set("ENC: " + enc_str);
// if (rate_limiter++ < 2) console.writeln("DNB: " + decoder.get_pdu_name(result.pdu_type));
if (result.cmce_type != 0xff) {
std::string msg = "";
if (result.cmce_type == 7)
msg = "SETUP";
else if (result.cmce_type == 1)
msg = "CALL PROCEEDING";
else if (result.cmce_type == 0)
msg = "ALERTING";
else if (result.cmce_type == 2)
msg = "CONNECT";
else if (result.cmce_type == 3)
msg = "CONNECT ACK";
else if (result.cmce_type == 11)
msg = "TX GRANTED";
else if (result.cmce_type == 9)
msg = "TX CEASED";
else if (result.cmce_type == 13)
msg = "TX WAIT";
else if (result.cmce_type == 10)
msg = "TX CONTINUE";
else if (result.cmce_type == 12)
msg = "TX INTERRUPT";
else if (result.cmce_type == 4)
msg = "DISCONNECT";
else if (result.cmce_type == 6)
msg = "RELEASE";
else if (result.cmce_type == 5)
msg = "INFO";
else if (result.cmce_type == 8)
msg = "STATUS";
else if (result.cmce_type == 14)
msg = "SDS DATA";
else if (result.cmce_type == 15)
msg = "FACILITY";
else if (result.cmce_type == 16)
msg = "CALL RESTORE";
else if (result.cmce_type == 31)
msg = "NOT SUPPORTED";
else
msg = "CMCE:" + to_string_dec_uint(result.cmce_type);
console.writeln(msg);
if (result.call_id != 0xffff) {
console.writeln("ID:" + to_string_dec_uint(result.call_id) + " | SSI:" + to_string_dec_uint(result.calling_ssi));
}
}
}
}
void TetraRxView::on_timer() {
rate_limiter = 0;
}
TetraRxView::~TetraRxView() {
receiver_model.disable();
baseband::shutdown();
}
} // namespace ui::external_app::tetra_rx
+192
View File
@@ -0,0 +1,192 @@
#ifndef __UI_TETRA_RX_H__
#define __UI_TETRA_RX_H__
#include "ui.hpp"
#include "ui_language.hpp"
#include "ui_navigation.hpp"
#include "ui_receiver.hpp"
#include "ui_freq_field.hpp"
#include "app_settings.hpp"
#include "radio_state.hpp"
#include "log_file.hpp"
#include "utility.hpp"
#include "message.hpp"
#include <array>
#include <string>
using namespace ui;
namespace ui::external_app::tetra_rx {
class TetraChannelDecoder {
public:
struct Result {
enum class Type { None,
Sync,
SyncFull,
Dnb };
Type type{Type::None};
bool is_ok{false};
uint8_t sync_errors{0};
bool inverted{false};
uint16_t crc{0};
int cost{0};
uint8_t timeslot{0xff};
uint8_t frame_number{0xff};
uint16_t mcc{0xffff};
uint16_t mnc{0xffff};
uint8_t bcc{0xff};
uint8_t pdu_type{0xff};
uint16_t la{0xffff};
uint8_t encryption{0xff};
uint8_t cmce_type{0xff};
uint16_t call_id{0xffff};
uint32_t calling_ssi{0};
std::array<uint8_t, 34> payload{};
};
std::string get_pdu_name(uint8_t pdu_type) {
switch (pdu_type) {
case 0:
return "MAC-RESOURCE";
case 1:
return "MAC-FRAG";
case 2:
return "SYSINFO/BCAST";
case 3:
return "MAC-U-SIGNAL";
default:
return "UNK_" + std::to_string(pdu_type);
}
}
Result decode_burst(const TetraBurstMessage& message);
Result decode_dnb(const TetraDnbMessage& message);
private:
static constexpr size_t BLK1_OFFSET_BITS = 94;
static constexpr size_t BLK1_LEN_BITS = 120;
static constexpr size_t TMO_BLK2_OFFSET_BITS = 282;
static constexpr size_t BLK2_LEN_BITS = 216;
static constexpr size_t SB1_TYPE1_BITS = 60;
static constexpr size_t SB1_TYPE2_BITS = 80;
static constexpr size_t SB1_INTERLEAVE_A = 11;
static constexpr size_t SB2_TYPE1_BITS = 124;
static constexpr size_t SB2_TYPE2_BITS = 144;
static constexpr size_t SB2_INTERLEAVE_A = 101;
static constexpr size_t SCH_F_LEN_BITS = 432;
static constexpr size_t SCH_F_TYPE1_BITS = 268;
static constexpr size_t SCH_F_TYPE2_BITS = 288;
static constexpr size_t SCH_F_INTERLEAVE_A = 103;
uint16_t last_mcc{0};
uint16_t last_mnc{0};
uint8_t last_bcc{0};
bool network_synced{false};
// Viterbi buffer
uint8_t trace_buffer[9216];
template <size_t OutBytes>
bool decode_block(const std::array<uint8_t, 63>& burst,
size_t offset,
size_t len_t5,
size_t type1_bits,
size_t type2_bits,
size_t interleave_a,
std::array<uint8_t, OutBytes>& out,
uint16_t& crc,
int& cost,
uint32_t scramb_init);
void parse_sync_pdu(Result& result);
void parse_mac_pdu(Result& result);
uint32_t read_bits(const uint8_t* bytes, size_t bit, size_t count) const;
template <size_t N>
uint8_t get_bit(const std::array<uint8_t, N>& arr, size_t bit_idx) const {
return (arr[bit_idx / 8] >> (7 - (bit_idx % 8))) & 0x01;
}
template <size_t N>
void set_bit(std::array<uint8_t, N>& arr, size_t bit_idx, uint8_t val) const {
if (val)
arr[bit_idx / 8] |= (1 << (7 - (bit_idx % 8)));
else
arr[bit_idx / 8] &= ~(1 << (7 - (bit_idx % 8)));
}
};
class TetraRxView : public View {
public:
TetraRxView(NavigationView& nav);
~TetraRxView();
void focus() override;
std::string title() const override { return "Tetra RX"; };
private:
NavigationView& nav_;
RxRadioState radio_state_{};
app_settings::SettingsManager settings_{"rx_tetra", app_settings::Mode::RX};
uint32_t packet_count{0};
uint32_t valid_count{0};
uint32_t h_valid_count{0};
uint32_t dnb_count{0};
RxFrequencyField field_frequency{{UI_POS_X(0), UI_POS_Y(0)}, nav_};
RFAmpField field_rf_amp{{UI_POS_X(13), UI_POS_Y(0)}};
LNAGainField field_lna{{UI_POS_X(15), UI_POS_Y(0)}};
VGAGainField field_vga{{UI_POS_X(18), UI_POS_Y(0)}};
RSSI rssi{{UI_POS_X_RIGHT(9), UI_POS_Y(0), UI_POS_WIDTH(9), 4}};
Channel channel{{UI_POS_X_RIGHT(9), UI_POS_Y(0) + 5, UI_POS_WIDTH(9), 4}};
Text text_mcc{{UI_POS_X(0), UI_POS_Y(1), UI_POS_WIDTH(15), UI_POS_HEIGHT(1)}, "MCC: ---"};
Text text_mnc{{UI_POS_X(15), UI_POS_Y(1), UI_POS_WIDTH(15), UI_POS_HEIGHT(1)}, "MNC: ---"};
Text text_ts{{UI_POS_X(0), UI_POS_Y(2), UI_POS_WIDTH(15), UI_POS_HEIGHT(1)}, "TS: -"}; // time slot
Text text_fn{{UI_POS_X(15), UI_POS_Y(2), UI_POS_WIDTH(15), UI_POS_HEIGHT(1)}, "FN: -"}; // frame number
Text text_bcc{{UI_POS_X(0), UI_POS_Y(3), UI_POS_WIDTH(15), UI_POS_HEIGHT(1)}, "BCC: -"}; // Base Station Color Code
Text text_enc{{UI_POS_X(15), UI_POS_Y(3), UI_POS_WIDTH(15), UI_POS_HEIGHT(1)}, "ENC: -"}; // encoded or not
Text text_la{{UI_POS_X(0), UI_POS_Y(4), UI_POS_WIDTH(15), UI_POS_HEIGHT(1)}, "LA: ----"}; // Location Area
Text text_pdu{{UI_POS_X(15), UI_POS_Y(4), UI_POS_WIDTH(15), UI_POS_HEIGHT(1)}, "PDU: ----"}; // pdu content type
Text text_debug{{UI_POS_X(0), UI_POS_Y(5), UI_POS_MAXWIDTH, UI_POS_HEIGHT(1)}, "Syn: 0, V: 0, H: 0, E:0"}; // sync, valid, h_valid, errors corrected in top part
Console console{{UI_POS_X(0), UI_POS_Y(6) + 8, UI_POS_MAXWIDTH, screen_height - (UI_POS_Y(7) + 8)}};
TetraChannelDecoder decoder{};
void on_data_tetra(const TetraBurstMessage&);
void on_data_dnb(const TetraDnbMessage&);
uint8_t rate_limiter{0}; // limiter, to keep the device responsive
void on_timer();
MessageHandlerRegistration message_handler_frame_sync{
Message::ID::DisplayFrameSync,
[this](const Message* const) {
this->on_timer();
}};
MessageHandlerRegistration message_handler_packet{
Message::ID::TetraBsch,
[this](Message* const p) {
this->on_data_tetra(*static_cast<const TetraBurstMessage*>(p));
}};
MessageHandlerRegistration message_handler_dnb{
Message::ID::TetraDnb,
[this](Message* const p) {
this->on_data_dnb(*static_cast<const TetraDnbMessage*>(p));
}};
};
} // namespace ui::external_app::tetra_rx
#endif
+9
View File
@@ -785,6 +785,15 @@ set(MODE_CPPSRC
)
DeclareTargets(PTSK time_sink)
### Tetra Rx
set(MODE_CPPSRC
proc_tetra.cpp
)
DeclareTargets(PTET tetra_rx)
set(MODE_CPPSRC
sd_over_usb/proc_sd_over_usb.cpp
+239
View File
@@ -0,0 +1,239 @@
#include "proc_tetra.hpp"
#include "portapack_shared_memory.hpp"
#include "sine_table_int8.hpp"
#include "event_m4.hpp"
#include <algorithm>
TetraProcessor::TetraProcessor() {
decim_0.configure(taps_25k0_tetra_decim_0.taps);
decim_1.configure(taps_25k0_tetra_decim_1.taps);
baseband_thread.start();
configured = true;
}
void TetraProcessor::execute(const buffer_c8_t& buffer) {
if (!configured) return;
const auto decim_0_out = decim_0.execute(buffer, dst_buffer_0);
const auto channel_out = decim_1.execute(decim_0_out, dst_buffer_1);
feed_channel_stats(channel_out);
for (size_t i = 0; i < channel_out.count; i++) {
complex16_t sample = channel_out.p[i];
// Carrier PLL Rotation
uint8_t phase_idx = (pll_phase >> 24) & 0xFF;
uint8_t cos_idx = (phase_idx + 64) & 0xFF;
int16_t rotated_real = (sample.real() * sine_table_i8[cos_idx] - sample.imag() * sine_table_i8[phase_idx]) >> 7;
int16_t rotated_imag = (sample.real() * sine_table_i8[phase_idx] + sample.imag() * sine_table_i8[cos_idx]) >> 7;
complex16_t rotated_sample = {rotated_real, rotated_imag};
uint32_t old_phase = symbol_phase;
symbol_phase += symbol_phase_inc;
// Half-symbol timing
if ((old_phase < 0x80000000) && (symbol_phase >= 0x80000000)) {
mid_sample = rotated_sample;
}
// Full-symbol timing
if (symbol_phase < old_phase) {
prev_prompt = prompt_sample;
prompt_sample = rotated_sample;
// Gardner Timing Error Detector
int32_t err_i = (mid_sample.real() * (prompt_sample.real() - prev_prompt.real()));
int32_t err_q = (mid_sample.imag() * (prompt_sample.imag() - prev_prompt.imag()));
int32_t timing_error = err_i + err_q;
const int64_t delta = (timing_error >> 16);
int64_t inc = static_cast<int64_t>(symbol_phase_inc) + delta;
// CLAMPING: Prevent the NCO from drifting into the noise!
if (inc < 1600000000) inc = 1600000000;
if (inc > 1620000000) inc = 1620000000;
symbol_phase_inc = static_cast<uint32_t>(inc);
process_symbol(prompt_sample);
}
}
}
void TetraProcessor::process_symbol(const complex16_t& current_sample) {
// Differential phase demodulation
int32_t dot_i = (current_sample.real() * delay_sample.real()) + (current_sample.imag() * delay_sample.imag());
int32_t dot_q = (current_sample.imag() * delay_sample.real()) - (current_sample.real() * delay_sample.imag());
delay_sample = current_sample;
// Phase error for Carrier PLL
int32_t phase_err = 0;
if (dot_i > 0 && dot_q > 0)
phase_err = dot_q - dot_i;
else if (dot_i < 0 && dot_q > 0)
phase_err = dot_q + dot_i;
else if (dot_i < 0 && dot_q < 0)
phase_err = -dot_q + dot_i;
else
phase_err = -dot_q - dot_i;
pll_freq += (phase_err * pll_beta) >> 8;
// CLAMPING: Prevent frequency tracking from flying away
if (pll_freq > 400000000) pll_freq = 400000000;
if (pll_freq < -400000000) pll_freq = -400000000;
pll_phase += ((phase_err * pll_alpha) >> 8) + pll_freq;
// ETSI EN 300 392-2 pi/4 DQPSK bit mapping
uint8_t dibit = 0;
if (dot_i > 0 && dot_q > 0)
dibit = 0b00;
else if (dot_i < 0 && dot_q > 0)
dibit = 0b01;
else if (dot_i < 0 && dot_q < 0)
dibit = 0b11;
else
dibit = 0b10;
// Insert bits into history buffer and sync register
for (int b = 1; b >= 0; b--) {
uint8_t bit_val = (dibit >> b) & 0x01;
size_t byte_idx = (history_write_idx / 8) % 128;
if ((history_write_idx % 8) == 0) bit_history_buffer[byte_idx] = 0;
bit_history_buffer[byte_idx] |= (bit_val << (7 - (history_write_idx % 8)));
history_write_idx = (history_write_idx + 1) % 1024;
bit_count++;
sync_register = ((sync_register << 1) | bit_val);
uint64_t sync =
sync_register & 0x3FFFFFFFFFULL;
uint32_t err_pos =
__builtin_popcountll(sync ^ Y_SYNC);
uint32_t err_neg =
__builtin_popcountll(
sync ^
(~Y_SYNC & 0x3FFFFFFFFFULL));
if (!pending_dsb.valid && bit_count >= Y_SYNC_BITS && (err_pos <= 4 || err_neg <= 4)) {
const uint64_t sync_start = bit_count - Y_SYNC_BITS;
if (sync_start >= SYNC_OFFSET) {
pending_dsb.valid = true;
pending_dsb.burst_start = sync_start - SYNC_OFFSET;
pending_dsb.ready_at = pending_dsb.burst_start + BURST_BITS;
pending_dsb.inverted = err_neg < err_pos;
pending_dsb.errors = std::min(err_pos, err_neg);
}
}
if (ENABLE_DNB_MESSAGES) {
const uint32_t train_mask = (1UL << DNB_TRAIN_BITS) - 1;
const uint32_t train = sync_register & train_mask;
const uint32_t n_err_pos = __builtin_popcount(train ^ N_SYNC);
const uint32_t n_err_neg = __builtin_popcount(train ^ (~N_SYNC & train_mask));
const uint32_t p_err_pos = __builtin_popcount(train ^ P_SYNC);
const uint32_t p_err_neg = __builtin_popcount(train ^ (~P_SYNC & train_mask));
if (!pending_dnb.valid && bit_count >= DNB_TRAIN_BITS && (n_err_pos <= 1 || n_err_neg <= 1 || p_err_pos <= 1 || p_err_neg <= 1)) {
const bool p_train = std::min(p_err_pos, p_err_neg) < std::min(n_err_pos, n_err_neg);
const uint32_t pos_err = p_train ? p_err_pos : n_err_pos;
const uint32_t neg_err = p_train ? p_err_neg : n_err_neg;
const uint64_t train_start = bit_count - DNB_TRAIN_BITS;
const uint64_t block1_start = train_start - 230;
const uint64_t block2_start = train_start + 38;
if (train_start >= 230) {
pending_dnb.valid = true;
pending_dnb.block1_start = block1_start;
pending_dnb.block2_start = block2_start;
pending_dnb.ready_at = block2_start + DNB_BLK_BITS;
pending_dnb.inverted = neg_err < pos_err;
pending_dnb.p_train = p_train;
pending_dnb.errors = std::min(pos_err, neg_err);
}
}
if (pending_dnb.valid && bit_count >= pending_dnb.ready_at)
push_dnb_to_ui();
}
if (pending_dsb.valid && bit_count >= pending_dsb.ready_at)
push_burst_to_ui();
}
}
uint8_t TetraProcessor::history_bit(uint64_t absolute_bit) const {
const size_t p = absolute_bit % 1024;
return (bit_history_buffer[p >> 3] >>
(7 - (p & 7))) &
1;
}
void TetraProcessor::push_burst_to_ui() {
std::array<uint8_t, 63> burst{};
for (size_t i = 0; i < BURST_BITS; i++) {
uint8_t b = history_bit(pending_dsb.burst_start + i);
if (pending_dsb.inverted)
b ^= 1;
burst[i >> 3] |=
b << (7 - (i & 7));
}
shared_memory.application_queue.push(
TetraBurstMessage(
burst.data(),
pending_dsb.inverted,
pending_dsb.errors));
pending_dsb.valid = false;
}
void TetraProcessor::push_dnb_to_ui() {
std::array<uint8_t, 54> burst{};
for (size_t i = 0; i < DNB_BLK_BITS; i++) {
uint8_t b = history_bit(pending_dnb.block1_start + i);
if (pending_dnb.inverted)
b ^= 1;
burst[i >> 3] |= b << (7 - (i & 7));
}
for (size_t i = 0; i < DNB_BLK_BITS; i++) {
uint8_t b = history_bit(pending_dnb.block2_start + i);
if (pending_dnb.inverted)
b ^= 1;
const size_t out_bit = DNB_BLK_BITS + i;
burst[out_bit >> 3] |= b << (7 - (out_bit & 7));
}
shared_memory.application_queue.push(
TetraDnbMessage(
burst.data(),
pending_dnb.inverted,
pending_dnb.errors,
pending_dnb.p_train));
pending_dnb.valid = false;
}
void TetraProcessor::on_message(const Message* const msg) {
(void)msg;
}
int main() {
EventDispatcher event_dispatcher{std::make_unique<TetraProcessor>()};
event_dispatcher.run();
return 0;
}
+94
View File
@@ -0,0 +1,94 @@
#ifndef __PROC_TETRA_H__
#define __PROC_TETRA_H__
#include "baseband_processor.hpp"
#include "baseband_thread.hpp"
#include "message.hpp"
#include "dsp_decimate.hpp"
#include "dsp_fir_taps.hpp"
#include "rssi_thread.hpp"
#include <cstdint>
#include <array>
#include <memory>
class TetraProcessor : public BasebandProcessor {
public:
TetraProcessor();
void execute(const buffer_c8_t& buffer) override;
void on_message(const Message* const msg);
private:
static constexpr size_t baseband_fs = 3072000;
static constexpr size_t channel_fs = 48000;
static constexpr uint32_t symbol_rate = 18000;
std::array<complex16_t, 512> dst_0{};
const buffer_c16_t dst_buffer_0{dst_0.data(), dst_0.size()};
std::array<complex16_t, 512> dst_1{};
const buffer_c16_t dst_buffer_1{dst_1.data(), dst_1.size()};
dsp::decimate::FIRC8xR16x24FS4Decim8 decim_0{};
dsp::decimate::FIRC16xR16x32Decim8 decim_1{};
// PLL and Timing
uint32_t symbol_phase{0};
uint32_t symbol_phase_inc{(uint32_t)((symbol_rate * 4294967296.0) / channel_fs)};
complex16_t prompt_sample{0, 0};
complex16_t prev_prompt{0, 0};
complex16_t mid_sample{0, 0};
complex16_t delay_sample{0, 0};
static constexpr uint32_t BURST_BITS = 500;
static constexpr uint32_t Y_SYNC_BITS = 38;
static constexpr uint32_t SYNC_OFFSET = 214;
static constexpr uint64_t Y_SYNC = 0x30673A7067ULL;
static constexpr uint32_t DNB_TRAIN_BITS = 22;
static constexpr uint32_t DNB_BLK_BITS = 216;
static constexpr uint32_t DNB_TRAIN_TO_BLK2 = 22;
static constexpr uint32_t DNB_TOTAL_T5_BITS = DNB_BLK_BITS * 2;
static constexpr uint32_t N_SYNC = 0x343A74UL;
static constexpr uint32_t P_SYNC = 0x1E90DEUL;
static constexpr bool ENABLE_DNB_MESSAGES = true;
uint32_t pll_phase{0};
int32_t pll_freq{0};
static constexpr int32_t pll_alpha = 150;
static constexpr int32_t pll_beta = 2;
uint64_t sync_register{0};
std::array<uint8_t, 128> bit_history_buffer{};
size_t history_write_idx{0};
uint64_t bit_count{0};
bool configured{false};
struct PendingDnb {
bool valid{false};
uint64_t block1_start{0};
uint64_t block2_start{0};
uint64_t ready_at{0};
bool inverted{false};
bool p_train{false};
uint8_t errors{0};
} pending_dnb{};
struct PendingDsb {
bool valid{false};
uint64_t burst_start{0};
uint64_t ready_at{0};
bool inverted{false};
uint8_t errors{0};
} pending_dsb{};
void process_symbol(const complex16_t& sample);
uint8_t history_bit(uint64_t absolute_bit) const;
void push_burst_to_ui();
void push_dnb_to_ui();
BasebandThread baseband_thread{baseband_fs, this, baseband::Direction::Receive, false};
RSSIThread rssi_thread{};
};
#endif // __PROC_TETRA_H__
+16
View File
@@ -2070,4 +2070,20 @@ static constexpr fir_taps_complex<63> taps_1k5_LSB_channel = {
}},
};
// TETRA 25kHz filters
// IFIR wideband pre-filter: fs=3072000, pass=90000, decim=8, fout=384000
constexpr fir_taps_real<24> taps_25k0_tetra_decim_0 = {
.low_frequency_normalized = -90000.0f / 3072000.0f,
.high_frequency_normalized = 90000.0f / 3072000.0f,
.transition_normalized = 160000.0f / 3072000.0f,
.taps = {{55, 122, 244, 424, 666, 965, 1308, 1669, 2019, 2321, 2544, 2663, 2663, 2544, 2321, 2019, 1669, 1308, 965, 666, 424, 244, 122, 55}},
};
// IFIR channel filter: fs=384000, pass=15000, decim=8, fout=48000
constexpr fir_taps_real<32> taps_25k0_tetra_decim_1 = {
.low_frequency_normalized = -15000.0f / 384000.0f,
.high_frequency_normalized = 15000.0f / 384000.0f,
.transition_normalized = 25000.0f / 384000.0f,
.taps = {{2, -18, -60, -124, -198, -259, -267, -177, 53, 448, 1003, 1677, 2398, 3068, 3585, 3868, 3868, 3585, 3068, 2398, 1677, 1003, 448, 53, -177, -267, -259, -198, -124, -60, -18, 2}},
};
#endif /*__DSP_FIR_TAPS_H__*/
+44
View File
@@ -168,6 +168,8 @@ class Message {
HunterConfig = 110,
HunterTrigger = 111,
HunterStop = 112,
TetraBsch = 113,
TetraDnb = 114,
MAX
};
@@ -2009,4 +2011,46 @@ class HunterStopMessage : public Message {
: Message{ID::HunterStop} {}
};
struct TetraBurstMessage : public Message {
constexpr TetraBurstMessage(
const uint8_t* bits,
bool inv,
uint8_t err)
: Message(Message::ID::TetraBsch),
inverted(inv),
sync_errors(err),
payload{} {
for (size_t i = 0; i < 63; i++)
payload[i] = bits[i];
}
bool inverted;
uint8_t sync_errors;
// 500 bit
std::array<uint8_t, 63> payload;
};
struct TetraDnbMessage : public Message {
constexpr TetraDnbMessage(
const uint8_t* bits,
bool inv,
uint8_t err,
bool p_train)
: Message(Message::ID::TetraDnb),
inverted(inv),
train_errors(err),
is_p_train(p_train),
payload{} {
for (size_t i = 0; i < 54; i++)
payload[i] = bits[i];
}
bool inverted;
uint8_t train_errors;
bool is_p_train;
// 432 TCH type-5 bits: 216 bits before the training sequence + 216 bits after.
std::array<uint8_t, 54> payload;
};
#endif /*__MESSAGE_H__*/
+1
View File
@@ -134,6 +134,7 @@ constexpr image_tag_t image_tag_vor_rx{'P', 'V', 'R', 'X'};
constexpr image_tag_t image_tag_rttyrx{'P', 'R', 'T', 'R'};
constexpr image_tag_t image_tag_rttytx{'P', 'R', 'T', 'T'};
constexpr image_tag_t image_tag_tonedetect{'P', 'T', 'N', 'E'};
constexpr image_tag_t image_tag_tetrarx{'P', 'T', 'E', 'T'};
constexpr image_tag_t image_tag_noop{'P', 'N', 'O', 'P'};