TPMS TX Application with Enhanced RX Integration (#3001)

* Add TPMS transmit application with editable fields and update linker script

* Fix formatting in application information structure for TPMS TX

* Implement TPMS recent entry detail view and enhance TPMS TX encoding logic

* Refactor TPMS TX UI layout and streamline packet display updates

* Fix formatting by removing unnecessary blank line in TPMSTXView constructor

* Enhance TPMS transmission logic with signal type handling and update UI components

* Add advanced mode toggle and auto-select signal type based on packet type

* Enhance packet display and encoding logic with protocol-specific notes for temperature and ID handling

* Implement baseband switching logic for TPMS transmission based on signal type

* Implement application-level FSK repeat handling and initialize repeat counter

* Add FSK termination function and improve transmission handling

* Remove unused kill_fsk function and clean up whitespace in TPMS transmission logic

* Add pressure and temperature unit conversion functionality in TPMSTXView

* Add temperature and function field visibility handling in TPMSTXView

* Add signal type handling and improve data loading in TPMSTXView

* Fix formatting of pressure unit string in TPMSRecentEntryDetailView

* Add pressure overflow checks and enhance transponder ID handling in TPMSTXView

* Update application information and change icon color to green in TPMS TX

* Update desired menu position for TPMS TX application
This commit is contained in:
jLynx
2026-02-18 06:02:27 +00:00
committed by GitHub
parent e9e78609b7
commit 15df51f976
11 changed files with 1336 additions and 3 deletions
+5
View File
@@ -83,6 +83,10 @@ set(EXTCPPSRC
external/tpmsrx/main.cpp
external/tpmsrx/tpms_app.cpp
#tpmstx 800 bytes - TPMS transmit with editable fields
external/tpmstx/main.cpp
external/tpmstx/tpms_tx_app.cpp
#protoview 8 byte
external/protoview/main.cpp
external/protoview/ui_protoview.cpp
@@ -326,6 +330,7 @@ set(EXTAPPLIST
audio_test
wardrivemap
tpmsrx
tpmstx
protoview
adsbtx
#morse_tx
+7
View File
@@ -97,6 +97,7 @@ MEMORY
ram_external_app_keeloqtx (rwx) : org = 0xADF80000, len = 32k
ram_external_app_rtty_rx (rwx) : org = 0xADF90000, len = 32k
ram_external_app_rtty_tx (rwx) : org = 0xADFA0000, len = 32k
ram_external_app_tpmstx (rwx) : org = 0xADFB0000, len = 32k
}
SECTIONS
@@ -215,6 +216,12 @@ SECTIONS
*(*ui*external_app*tpmsrx*);
} > ram_external_app_tpmsrx
.external_app_tpmstx : ALIGN(4) SUBALIGN(4)
{
KEEP(*(.external_app.app_tpmstx.application_information));
*(*ui*external_app*tpmstx*);
} > ram_external_app_tpmstx
.external_app_protoview : ALIGN(4) SUBALIGN(4)
{
KEEP(*(.external_app.app_protoview.application_information));
+1 -1
View File
@@ -75,7 +75,7 @@ __attribute__((section(".external_app.app_tpmsrx.application_information"), used
},
/*.icon_color = */ ui::Color::green().v,
/*.menu_location = */ app_location_t::RX,
/*.desired_menu_position = */ -1,
/*.desired_menu_position = */ 7,
/*.m4_app_tag = portapack::spi_flash::image_tag_tpms */ {'P', 'T', 'P', 'M'},
/*.m4_app_offset = */ 0x00000000, // will be filled at compile time
+137 -1
View File
@@ -42,6 +42,25 @@ std::string type(tpms::Reading::Type type) {
return to_string_dec_uint(toUType(type), 2);
}
std::string type_name(tpms::Reading::Type type) {
switch (type) {
case tpms::Reading::Type::None:
return "None";
case tpms::Reading::Type::FLM_64:
return "FLM_64";
case tpms::Reading::Type::FLM_72:
return "FLM_72";
case tpms::Reading::Type::FLM_80:
return "FLM_80";
case tpms::Reading::Type::Schrader:
return "Schrader";
case tpms::Reading::Type::GMC_96:
return "GMC_96";
default:
return "Unknown";
}
}
std::string id(tpms::TransponderID id) {
return to_string_hex(id.value(), 8);
}
@@ -101,7 +120,8 @@ void TPMSRecentEntry::update(const tpms::Reading& reading) {
}
}
TPMSAppView::TPMSAppView(NavigationView&) {
TPMSAppView::TPMSAppView(NavigationView& nav)
: nav_{nav} {
// baseband::run_image(portapack::spi_flash::image_tag_tpms);
baseband::run_prepared_image(portapack::memory::map::m4_code.base());
@@ -135,6 +155,11 @@ TPMSAppView::TPMSAppView(NavigationView&) {
};
options_temperature.set_by_value(format::temp_unit);
// Add on_select handler for entries
recent_entries_view.on_select = [this](const TPMSRecentEntry& entry) {
on_show_detail(entry);
};
logger = std::make_unique<TPMSLogger>();
if (logger) {
logger->append(logs_dir / u"TPMS.TXT");
@@ -178,6 +203,7 @@ void TPMSAppView::on_packet(const tpms::Packet& packet) {
const auto reading = reading_opt.value();
auto& entry = ::on_packet(recent, TPMSRecentEntry::Key{reading.type(), reading.id()});
entry.update(reading);
entry.signal_type = packet.signal_type();
recent_entries_view.set_dirty();
}
@@ -191,6 +217,116 @@ void TPMSAppView::on_show_list() {
recent_entries_view.focus();
}
void TPMSAppView::on_show_detail(const TPMSRecentEntry& entry) {
nav_.push<TPMSRecentEntryDetailView>(entry);
}
// Detail view implementation
TPMSRecentEntryDetailView::TPMSRecentEntryDetailView(NavigationView& nav, const TPMSRecentEntry& entry)
: nav_{nav},
entry_{entry} {
add_children({&labels,
&text_type,
&text_id,
&text_pressure,
&text_temperature,
&text_flags,
&text_count,
&button_done,
&button_save});
// Display entry data
text_type.set(format::type_name(entry.type));
text_id.set(to_string_hex(entry.id.value(), 8));
if (entry.last_pressure.is_valid()) {
std::string pressure_str = format::pressure(entry.last_pressure.value());
std::string unit_str = format::pressure_unit == PRESSURE_UNIT_PSI ? " PSI" : format::pressure_unit == PRESSURE_UNIT_BAR ? " BAR"
: " kPa";
text_pressure.set(pressure_str + unit_str);
} else {
text_pressure.set("---");
}
if (entry.last_temperature.is_valid()) {
text_temperature.set(to_string_dec_int(entry.last_temperature.value().celsius(), 3) + " C");
} else {
text_temperature.set("---");
}
if (entry.last_flags.is_valid()) {
text_flags.set(to_string_hex(entry.last_flags.value(), 2));
} else {
text_flags.set("--");
}
text_count.set(to_string_dec_uint(entry.received_count, 4));
button_done.on_select = [&nav](Button&) {
nav.pop();
};
button_save.on_select = [this](Button&) {
on_save();
};
}
void TPMSRecentEntryDetailView::focus() {
button_done.focus();
}
void TPMSRecentEntryDetailView::set_entry(const TPMSRecentEntry& entry) {
entry_ = entry;
}
void TPMSRecentEntryDetailView::on_save() {
auto timestamp = to_string_timestamp(rtc_time::now());
std::string file_name = "TPMS_" + timestamp + ".TXT";
ensure_directory(tpms_dir);
auto file_path = tpms_dir / file_name;
if (save_file(file_path)) {
nav_.display_modal("Saved", "Packet saved to:\n" + file_name);
} else {
nav_.display_modal("Error", "Failed to save\npacket");
}
}
bool TPMSRecentEntryDetailView::save_file(const std::filesystem::path& path) {
File f;
auto error = f.create(path);
if (error.is_valid())
return false;
// Save in format compatible with TX app
std::string content = "Type=" + to_string_dec_uint(toUType(entry_.type), 1) + "\n";
content += "ID=" + to_string_hex(entry_.id.value(), 8) + "\n";
if (entry_.last_pressure.is_valid()) {
content += "Pressure=" + to_string_dec_int(entry_.last_pressure.value().kilopascal(), 1) + "\n";
} else {
content += "Pressure=240\n"; // Default value
}
if (entry_.last_temperature.is_valid()) {
content += "Temperature=" + to_string_dec_int(entry_.last_temperature.value().celsius(), 1) + "\n";
} else {
content += "Temperature=25\n"; // Default value
}
if (entry_.last_flags.is_valid()) {
content += "Flags=" + to_string_hex(entry_.last_flags.value(), 2) + "\n";
} else {
content += "Flags=00\n";
}
// Save signal type for proper retransmission
content += "SignalType=" + to_string_dec_uint(toUType(entry_.signal_type), 1) + "\n";
f.write(content.c_str(), content.length());
return true;
}
} // namespace ui::external_app::tpmsrx
namespace ui {
+62
View File
@@ -53,6 +53,7 @@ struct TPMSRecentEntry {
tpms::Reading::Type type{invalid_key.first};
tpms::TransponderID id{invalid_key.second};
tpms::SignalType signal_type{tpms::SignalType::OOK_8k192_Schrader};
size_t received_count{0};
@@ -89,6 +90,64 @@ class TPMSLogger {
using TPMSRecentEntriesView = RecentEntriesView<TPMSRecentEntries>;
class TPMSRecentEntryDetailView : public View {
public:
TPMSRecentEntryDetailView(NavigationView& nav, const TPMSRecentEntry& entry);
void set_entry(const TPMSRecentEntry& entry);
const TPMSRecentEntry& entry() const { return entry_; }
void focus() override;
private:
NavigationView& nav_;
TPMSRecentEntry entry_;
void on_save();
bool save_file(const std::filesystem::path& path);
Labels labels{
{{0 * 8, 1 * 16}, "Type:", Theme::getInstance()->fg_light->foreground},
{{0 * 8, 3 * 16}, "ID:", Theme::getInstance()->fg_light->foreground},
{{0 * 8, 5 * 16}, "Pressure:", Theme::getInstance()->fg_light->foreground},
{{0 * 8, 7 * 16}, "Temperature:", Theme::getInstance()->fg_light->foreground},
{{0 * 8, 9 * 16}, "Flags:", Theme::getInstance()->fg_light->foreground},
{{0 * 8, 11 * 16}, "Count:", Theme::getInstance()->fg_light->foreground},
};
Text text_type{
{8 * 8, 1 * 16, 20 * 8, 16},
""};
Text text_id{
{8 * 8, 3 * 16, 20 * 8, 16},
""};
Text text_pressure{
{12 * 8, 5 * 16, 16 * 8, 16},
""};
Text text_temperature{
{14 * 8, 7 * 16, 14 * 8, 16},
""};
Text text_flags{
{8 * 8, 9 * 16, 20 * 8, 16},
""};
Text text_count{
{8 * 8, 11 * 16, 20 * 8, 16},
""};
Button button_save{
{0 * 8, 13 * 16, 14 * 8, 32},
"Save"};
Button button_done{
{16 * 8, 13 * 16, 14 * 8, 32},
"Done"};
};
class TPMSAppView : public View {
public:
TPMSAppView(NavigationView& nav);
@@ -105,6 +164,8 @@ class TPMSAppView : public View {
std::string title() const override { return "TPMS RX"; };
private:
NavigationView& nav_;
RxRadioState radio_state_{
314900000 /* frequency*/
,
@@ -188,6 +249,7 @@ class TPMSAppView : public View {
void on_packet(const tpms::Packet& packet);
void on_show_list();
void on_show_detail(const TPMSRecentEntry& entry);
void update_view();
};
+83
View File
@@ -0,0 +1,83 @@
/*
* Copyright (C) 2026
*
* 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.hpp"
#include "tpms_tx_app.hpp"
#include "ui_navigation.hpp"
#include "external_app.hpp"
namespace ui::external_app::tpmstx {
void initialize_app(ui::NavigationView& nav) {
nav.push<TPMSTXView>();
}
} // namespace ui::external_app::tpmstx
extern "C" {
__attribute__((section(".external_app.app_tpmstx.application_information"), used)) application_information_t _application_information_tpmstx = {
/*.memory_location = */ (uint8_t*)0x00000000, // will be filled at compile time
/*.externalAppEntry = */ ui::external_app::tpmstx::initialize_app,
/*.header_version = */ CURRENT_HEADER_VERSION,
/*.app_version = */ VERSION_MD5,
/*.app_name = */ "TPMS TX",
/*.bitmap_data = */ {
0xC0,
0x03,
0xF0,
0x0F,
0x18,
0x18,
0xEC,
0x37,
0x36,
0x6D,
0x3A,
0x59,
0x4B,
0xD5,
0x8B,
0xD3,
0xCB,
0xD1,
0xAB,
0xD2,
0x9A,
0x5C,
0xB6,
0x6C,
0xEC,
0x37,
0x18,
0x18,
0xF0,
0x0F,
0xC0,
0x03,
},
/*.icon_color = */ ui::Color::green().v,
/*.menu_location = */ app_location_t::TX,
/*.desired_menu_position = */ 6,
/*.m4_app_tag = portapack::spi_flash::image_tag_ook */ {'P', 'O', 'O', 'K'},
/*.m4_app_offset = */ 0x00000000, // will be filled at compile time
};
}
+812
View File
@@ -0,0 +1,812 @@
/*
* Copyright (C) 2026
*
* 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 "tpms_tx_app.hpp"
#include "baseband_api.hpp"
#include "portapack.hpp"
#include "spi_image.hpp"
#include "ui_fileman.hpp"
#include "ui_freqman.hpp"
#include "manchester.hpp"
#include "rtc_time.hpp"
#include "file_path.hpp"
#include "encoders.hpp"
#include "crc.hpp"
using namespace portapack;
using namespace tpms;
namespace ui::external_app::tpmstx {
void TPMSTXView::focus() {
options_packet_type.focus();
}
void TPMSTXView::update_signal_type_from_packet() {
// Auto-select signal type based on packet type
switch (packet_type_) {
case tpms::Reading::Type::Schrader:
signal_type_ = tpms::SignalType::OOK_8k192_Schrader;
break;
case tpms::Reading::Type::FLM_64:
case tpms::Reading::Type::FLM_72:
case tpms::Reading::Type::FLM_80:
signal_type_ = tpms::SignalType::FSK_19k2_Schrader;
break;
case tpms::Reading::Type::GMC_96:
signal_type_ = tpms::SignalType::OOK_8k4_Schrader;
break;
default:
signal_type_ = tpms::SignalType::OOK_8k192_Schrader;
break;
}
// Note: Don't call switch_baseband() here - it's called when needed
}
void TPMSTXView::switch_baseband() {
// Switch baseband image based on signal type
// Must shutdown first to avoid BBRunning error
baseband::shutdown();
chThdSleepMilliseconds(100);
if (signal_type_ == tpms::SignalType::FSK_19k2_Schrader) {
baseband::run_image(portapack::spi_flash::image_tag_fsktx);
} else {
baseband::run_image(portapack::spi_flash::image_tag_ook);
}
chThdSleepMilliseconds(100);
}
void TPMSTXView::update_packet_display() {
// Only update status text - field values are already set by user interaction
// or by explicit set_value calls when loading from file
std::string status = "ID:" + to_string_hex(transponder_id_, 8);
// Note protocol-specific limitations
if (packet_type_ == tpms::Reading::Type::Schrader) {
status = "ID:" + to_string_hex(transponder_id_ & 0x00FFFFFF, 6) + " (24bit)";
}
status += " " + to_string_dec_uint(pressure_kpa_) + "kPa";
// Check for pressure overflow and warn user
if (packet_type_ == tpms::Reading::Type::GMC_96) {
if (pressure_kpa_ > 701) {
status += " !MAX"; // GMC_96 max is 701 kPa (101.7 PSI)
}
} else {
if (pressure_kpa_ > 340) {
status += " !MAX"; // Schrader/FLM max is 340 kPa (49.3 PSI)
}
}
// Temperature note for protocols that support it
if (packet_type_ == tpms::Reading::Type::GMC_96 ||
packet_type_ == tpms::Reading::Type::FLM_64 ||
packet_type_ == tpms::Reading::Type::FLM_72 ||
packet_type_ == tpms::Reading::Type::FLM_80) {
status += " " + to_string_dec_int(temperature_c_) + "C";
} else {
status += " (no temp)";
}
text_status.set(status);
}
void TPMSTXView::update_field_visibility() {
// Schrader: Show Func, Hide Temp (no temperature support)
// GMC_96, FLM_xx: Hide Func, Show Temp (temperature supported, no func field)
if (packet_type_ == tpms::Reading::Type::Schrader) {
// Show Func field and label
label_flags.hidden(false);
field_flags.hidden(false);
// Hide Temp field, label, and unit selector
label_temperature.hidden(true);
field_temperature.hidden(true);
options_temperature.hidden(true);
// Show 24-bit ID field, hide 32-bit ID field
field_transponder_id_24.hidden(false);
field_transponder_id_32.hidden(true);
} else {
// Hide Func field and label
label_flags.hidden(true);
field_flags.hidden(true);
// Show Temp field, label, and unit selector
label_temperature.hidden(false);
field_temperature.hidden(false);
options_temperature.hidden(false);
// Show 32-bit ID field, hide 24-bit ID field
field_transponder_id_24.hidden(true);
field_transponder_id_32.hidden(false);
}
// Force screen refresh to clear hidden widgets
set_dirty();
}
void TPMSTXView::on_pressure_unit_change() {
// Convert displayed pressure to new unit and update field
units::Pressure pressure(pressure_kpa_);
int display_value = 0;
if (format::pressure_unit == PRESSURE_UNIT_PSI) {
display_value = pressure.psi();
} else if (format::pressure_unit == PRESSURE_UNIT_BAR) {
display_value = pressure.bar();
} else {
display_value = pressure.kilopascal();
}
field_pressure.set_value(display_value);
}
void TPMSTXView::on_temperature_unit_change() {
// Convert displayed temperature to new unit and update field
units::Temperature temperature(temperature_c_);
int display_value = 0;
if (format::temp_unit == TEMP_UNIT_FAHRENHEIT) {
display_value = temperature.fahrenheit();
} else {
display_value = temperature.celsius();
}
field_temperature.set_value(display_value);
}
void TPMSTXView::encode_and_transmit() {
if (!is_transmitting_) return;
// Build TPMS packet based on signal type
std::string binary_string;
uint32_t symbol_rate;
uint32_t sample_rate;
// Set sample rate based on signal type to match transmitter config
if (signal_type_ == tpms::SignalType::FSK_19k2_Schrader) {
sample_rate = 2280000; // 2.28 MHz for FSK (matches transmitter_model config)
} else {
sample_rate = 2000000; // 2 MHz for OOK
}
if (signal_type_ == tpms::SignalType::OOK_8k192_Schrader) {
// Schrader OOK 8k192 format (Type = Schrader)
// Preamble: 11*2, 01*14, 11, 10
// Data: 3 bits function code, 24 bits ID, 8 bits pressure, 2 bits checksum
// Total: 37 Manchester symbols (74 bits after encoding)
// NOTE: This protocol does NOT support temperature transmission
// NOTE: Only lower 24 bits of ID are transmitted
// NOTE: Function code (flags_) is stored as 3-bit value (0-7)
// RX will display it combined with checksum in upper nibble
symbol_rate = 8192;
// Preamble as expected by RX decoder
binary_string = "1111"; // 11*2
for (int i = 0; i < 14; i++) {
binary_string += "01";
}
binary_string += "1110"; // 11, 10
// Build data bits (pre-Manchester)
uint64_t data = 0;
// 3-bit flags (0-7, stored directly)
uint8_t flags_3bit = flags_ & 0x07;
data |= ((uint64_t)flags_3bit << 34);
// 24-bit ID (only use lower 24 bits - protocol limitation)
uint32_t id_24bit = transponder_id_ & 0x00FFFFFF;
data |= ((uint64_t)id_24bit << 10);
// 8-bit pressure (convert kPa to raw: kPa * 3/4)
// Clamp to prevent overflow: max raw = 255, max kPa = 255 * 4/3 = 340 (49.3 PSI)
uint16_t pressure_clamped = (pressure_kpa_ > 340) ? 340 : pressure_kpa_;
uint8_t pressure_raw = (pressure_clamped * 3 / 4);
data |= ((uint64_t)pressure_raw << 2);
// Calculate 2-bit checksum: sum all 2-bit pairs, result & 3 must equal 3
uint32_t checksum_calc = (data >> 36) & 1; // First bit
for (size_t i = 1; i < 37; i += 2) {
checksum_calc += (data >> (37 - i - 2)) & 3;
}
uint8_t checksum_2bit = (3 - (checksum_calc & 3)) & 3;
data |= checksum_2bit;
// Manchester encode the 37 data bits
for (int i = 36; i >= 0; i--) {
if ((data >> i) & 1) {
binary_string += "10"; // '1' = 10 in Manchester
} else {
binary_string += "01"; // '0' = 01 in Manchester
}
}
} else if (signal_type_ == tpms::SignalType::OOK_8k4_Schrader) {
// GMC_96 OOK 8k4 format
symbol_rate = 8400;
// Preamble: 01*40
for (int i = 0; i < 40; i++) {
binary_string += "01";
}
// First nibble of System ID (0x4) - part of preamble pattern match
// RX looks for pattern ending with: 01010101...01100101
// The "01100101" = Manchester for 0x4, and is consumed by pattern matcher
binary_string += "01"; // 0
binary_string += "10"; // 1
binary_string += "01"; // 0
binary_string += "01"; // 0 (0100 = 0x4)
// Remaining 20 bits of system ID (padding with zeros)
// These are the first 20 bits of the 76-bit payload
for (int i = 0; i < 20; i++) {
binary_string += "01"; // All zeros for padding
}
// 32-bit ID (Manchester encoded)
for (int i = 31; i >= 0; i--) {
if ((transponder_id_ >> i) & 1) {
binary_string += "10";
} else {
binary_string += "01";
}
}
// Pressure: kPa * 4/11
// Clamp to prevent overflow: max raw = 255, max kPa = 255 * 11/4 = 701.25 (101.7 PSI)
uint16_t pressure_clamped = (pressure_kpa_ > 701) ? 701 : pressure_kpa_;
uint8_t pressure_gmc = (pressure_clamped * 4 / 11);
for (int i = 7; i >= 0; i--) {
if ((pressure_gmc >> i) & 1) {
binary_string += "10";
} else {
binary_string += "01";
}
}
// Temperature: temp + 61
uint8_t temp_gmc = (temperature_c_ + 61) & 0xFF;
for (int i = 7; i >= 0; i--) {
if ((temp_gmc >> i) & 1) {
binary_string += "10";
} else {
binary_string += "01";
}
}
// Checksum: sum of all data bytes (system ID + ID + pressure + temp)
// System ID byte 0: (0x4 << 4) | 0x0 = 0x40
// System ID byte 1: 0x00
// System ID byte 2: 0x00
uint8_t checksum = 0x40; // First byte of system ID
checksum += 0x00; // Second byte of system ID
checksum += 0x00; // Third byte of system ID
// Add all 4 bytes of the ID
checksum += (transponder_id_ >> 24) & 0xFF;
checksum += (transponder_id_ >> 16) & 0xFF;
checksum += (transponder_id_ >> 8) & 0xFF;
checksum += transponder_id_ & 0xFF;
// Add pressure and temperature
checksum += pressure_gmc;
checksum += temp_gmc;
for (int i = 7; i >= 0; i--) {
if ((checksum >> i) & 1) {
binary_string += "10";
} else {
binary_string += "01";
}
}
} else if (signal_type_ == tpms::SignalType::FSK_19k2_Schrader) {
// FSK 19.2k for FLM variants
// Data is Manchester encoded: each data bit becomes two FSK symbols
// Preamble: 14 x '01' + '10' = 30 bits (matches RX pattern exactly)
// Payload: 160 Manchester-encoded bits (80 data bits max)
symbol_rate = 19200;
// Build preamble: 14 pairs of "01" followed by "10"
for (int i = 0; i < 14; i++) {
binary_string += "01";
}
binary_string += "10";
std::array<uint8_t, 20> data_bytes = {0}; // 160 bits = 20 bytes max
size_t num_data_bits = 0;
if (packet_type_ == tpms::Reading::Type::FLM_64) {
// FLM_64: 64 bits (8 bytes)
// Bytes 0-3: ID (32 bits)
// Byte 4: Pressure (8 bits)
// Byte 5: Temperature (8 bits, LSB 7 bits used)
// Byte 6: Padding
// Byte 7: Sum checksum of bytes 0-6 (low 8 bits)
num_data_bits = 64;
data_bytes[0] = (transponder_id_ >> 24) & 0xFF;
data_bytes[1] = (transponder_id_ >> 16) & 0xFF;
data_bytes[2] = (transponder_id_ >> 8) & 0xFF;
data_bytes[3] = transponder_id_ & 0xFF;
// Clamp pressure to prevent overflow: max 340 kPa (49.3 PSI)
uint16_t pressure_clamped_flm64 = (pressure_kpa_ > 340) ? 340 : pressure_kpa_;
data_bytes[4] = (pressure_clamped_flm64 * 3 / 4);
data_bytes[5] = ((temperature_c_ + 56) & 0x7F); // LSB 7 bits only
data_bytes[6] = 0x00; // Padding
// Addition checksum over bytes 0-6
uint32_t checksum = 0;
for (int i = 0; i < 7; i++) {
checksum += data_bytes[i];
}
data_bytes[7] = checksum & 0xFF;
} else if (packet_type_ == tpms::Reading::Type::FLM_72) {
// FLM_72: 72 bits (9 bytes)
// Bytes 0-3: ID (32 bits)
// Byte 4: Padding
// Byte 5: Pressure (8 bits)
// Byte 6: Temperature (8 bits)
// Bytes 7-8: CRC field - RX processes ALL 9 bytes and expects CRC result = 0
num_data_bits = 72;
data_bytes[0] = (transponder_id_ >> 24) & 0xFF;
data_bytes[1] = (transponder_id_ >> 16) & 0xFF;
data_bytes[2] = (transponder_id_ >> 8) & 0xFF;
data_bytes[3] = transponder_id_ & 0xFF;
data_bytes[4] = 0x00; // Padding
// Clamp pressure to prevent overflow: max 340 kPa (49.3 PSI)
uint16_t pressure_clamped_flm = (pressure_kpa_ > 340) ? 340 : pressure_kpa_;
data_bytes[5] = (pressure_clamped_flm * 3 / 4);
data_bytes[6] = (temperature_c_ + 56) & 0xFF;
data_bytes[7] = 0x00; // Set to 0 initially
// Calculate CRC over bytes 0-7, place result in byte 8
// When RX calculates CRC over bytes 0-8, it should get residual 0
CRC<8> crc{0x01, 0x00};
for (int i = 0; i < 8; i++) {
crc.process_byte(data_bytes[i]);
}
data_bytes[8] = crc.checksum() & 0xFF;
} else if (packet_type_ == tpms::Reading::Type::FLM_80) {
// FLM_80: 80 bits (10 bytes)
// Byte 0: Padding
// Bytes 1-4: ID (32 bits)
// Byte 5: Padding
// Byte 6: Pressure (8 bits)
// Byte 7: Temperature (8 bits)
// Bytes 8-9: CRC field - RX processes bytes 1-9 and expects CRC result = 0
num_data_bits = 80;
data_bytes[0] = 0x00; // Padding (not included in CRC)
data_bytes[1] = (transponder_id_ >> 24) & 0xFF;
data_bytes[2] = (transponder_id_ >> 16) & 0xFF;
data_bytes[3] = (transponder_id_ >> 8) & 0xFF;
data_bytes[4] = transponder_id_ & 0xFF;
data_bytes[5] = 0x00; // Padding
// Clamp pressure to prevent overflow: max 340 kPa (49.3 PSI)
uint16_t pressure_clamped_flm80 = (pressure_kpa_ > 340) ? 340 : pressure_kpa_;
data_bytes[6] = (pressure_clamped_flm80 * 3 / 4);
data_bytes[7] = (temperature_c_ + 56) & 0xFF;
data_bytes[8] = 0x00; // Padding/Reserved
data_bytes[9] = 0x00; // CRC byte
// Calculate CRC over bytes 1-8 (all bytes except 0 and 9)
// When RX calculates CRC over bytes 1-9, it should get residual 0
CRC<8> crc{0x01, 0x00};
for (int i = 1; i <= 8; i++) {
crc.process_byte(data_bytes[i]);
}
data_bytes[9] = crc.checksum() & 0xFF;
}
// Manchester-encode data bits for FSK
// RX ManchesterDecoder with sense=0: '1' = "10", '0' = "01"
size_t num_bytes = num_data_bits / 8;
for (size_t byte_idx = 0; byte_idx < num_bytes; byte_idx++) {
uint8_t byte_val = data_bytes[byte_idx];
for (int bit_idx = 7; bit_idx >= 0; bit_idx--) {
if ((byte_val >> bit_idx) & 1) {
binary_string += "10"; // Manchester '1'
} else {
binary_string += "01"; // Manchester '0'
}
}
}
// Pad payload to 160 bits with valid Manchester pairs ("01" = 0)
// Using proper Manchester encoding maintains clock recovery sync
while (binary_string.length() < 190) { // 30 preamble + 160 payload
binary_string += "01"; // Manchester-encoded zero
}
} else {
text_status.set("Unknown signal type");
stop_tx();
return;
}
// Convert binary string to bitstream
size_t bitstream_length = encoders::make_bitstream(binary_string);
// Calculate samples per bit (round to nearest for best timing accuracy)
uint32_t samples_per_bit = (sample_rate + symbol_rate / 2) / symbol_rate;
// Update status to show we're sending
text_status.set("TX: " + to_string_dec_uint(binary_string.length()) + " bits");
// Send via appropriate baseband (OOK or FSK)
if (signal_type_ == tpms::SignalType::FSK_19k2_Schrader) {
// FSK mode: 19.2 kbaud, 38.4 kHz shift
baseband::set_fsk_data(
bitstream_length,
samples_per_bit,
38400, // 38.4 kHz shift
256); // Progress notice interval
} else {
// OOK mode
baseband::set_ook_data(
bitstream_length,
samples_per_bit,
repeat_count_,
pause_duration_);
}
}
void TPMSTXView::handle_tx_complete() {
// For FSK (FLM packets), handle repeats at application level
// OOK repeats are handled by the baseband processor
if (signal_type_ == tpms::SignalType::FSK_19k2_Schrader) {
fsk_repeat_counter_++;
if (fsk_repeat_counter_ < repeat_count_) {
// More repeats needed - send the next one
// Update progress bar
progressbar.set_value(fsk_repeat_counter_);
// Wait for FSK processor to fully complete and reset
// This allows the shared memory bitstream to be safely rewritten
chThdSleepMilliseconds(50);
encode_and_transmit();
} else {
// All FSK repeats complete
stop_tx();
}
} else {
// OOK repeats are handled by baseband, just stop here
stop_tx();
}
}
void TPMSTXView::start_tx() {
if (is_transmitting_)
return;
is_transmitting_ = true;
progressbar.set_max(repeat_count_);
progressbar.set_value(0);
button_transmit.set_text("STOP TX");
text_status.set("Transmitting...");
// Initialize FSK repeat counter for application-level repeat handling
fsk_repeat_counter_ = 0;
// Switch to appropriate baseband before transmission
switch_baseband();
// Configure transmitter based on modulation type
if (signal_type_ == tpms::SignalType::FSK_19k2_Schrader) {
// FSK configuration
transmitter_model.set_sampling_rate(2280000); // 2.28 MHz for FSK
transmitter_model.set_baseband_bandwidth(1750000);
} else {
// OOK configuration
transmitter_model.set_sampling_rate(2000000); // 2 MHz for OOK
transmitter_model.set_baseband_bandwidth(1750000);
}
transmitter_model.enable();
// Start transmission
encode_and_transmit();
}
void TPMSTXView::stop_tx() {
if (!is_transmitting_)
return;
is_transmitting_ = false;
transmitter_model.disable();
button_transmit.set_text("START TX");
text_status.set("Transmission complete");
progressbar.set_value(0);
}
TPMSTXView::TPMSTXView(NavigationView& nav)
: nav_{nav} {
// Start with OOK baseband (will switch if needed)
baseband::run_image(portapack::spi_flash::image_tag_ook);
add_children({&labels,
&label_temperature,
&label_flags,
&options_frequency,
&options_packet_type,
&field_transponder_id_24,
&field_transponder_id_32,
&field_pressure,
&options_pressure,
&field_temperature,
&options_temperature,
&field_flags,
&field_repeat,
&button_load,
&button_save,
&tx_view,
&button_transmit,
&text_status,
&progressbar});
// Set label styles to match other labels (light grey)
label_temperature.set_style(Theme::getInstance()->fg_light);
label_flags.set_style(Theme::getInstance()->fg_light);
// Initialize values
options_frequency.set_by_value(transmitter_model.target_frequency());
options_packet_type.set_selected_index(0);
field_repeat.set_value(repeat_count_);
// Set initial signal type based on packet type
update_signal_type_from_packet();
// Initialize unit selection
options_pressure.set_by_value(format::pressure_unit);
options_temperature.set_by_value(format::temp_unit);
// Initialize field values from default member variables
// For pressure and temperature, use unit conversion to display in selected units
field_transponder_id_24.set_value(transponder_id_ & 0x00FFFFFF);
field_transponder_id_32.set_value(transponder_id_);
on_pressure_unit_change();
on_temperature_unit_change();
field_flags.set_value(flags_);
update_field_visibility();
update_packet_display();
// Event handlers
options_frequency.on_change = [this](size_t, OptionsField::value_t v) {
transmitter_model.set_target_frequency(v);
};
options_packet_type.on_change = [this](size_t, int32_t value) {
packet_type_ = static_cast<tpms::Reading::Type>(value);
update_signal_type_from_packet();
// Sync field values when switching packet types
if (packet_type_ == tpms::Reading::Type::Schrader) {
// Switching to Schrader: copy from 32-bit field to 24-bit field (masked)
transponder_id_ = field_transponder_id_32.to_integer() & 0x00FFFFFF;
field_transponder_id_24.set_value(transponder_id_);
} else {
// Switching from Schrader: copy from 24-bit field to 32-bit field
transponder_id_ = field_transponder_id_24.to_integer();
field_transponder_id_32.set_value(transponder_id_);
}
update_field_visibility();
update_packet_display();
};
options_pressure.on_change = [this](size_t, int32_t i) {
format::pressure_unit = (uint8_t)i;
on_pressure_unit_change();
};
options_temperature.on_change = [this](size_t, int32_t i) {
format::temp_unit = (uint8_t)i;
on_temperature_unit_change();
};
field_transponder_id_24.on_change = [this](SymField&) {
transponder_id_ = field_transponder_id_24.to_integer() & 0x00FFFFFF;
update_packet_display();
};
field_transponder_id_32.on_change = [this](SymField&) {
transponder_id_ = field_transponder_id_32.to_integer();
update_packet_display();
};
field_pressure.on_change = [this](int32_t value) {
// Convert from displayed unit to kPa for internal storage
if (format::pressure_unit == PRESSURE_UNIT_PSI) {
pressure_kpa_ = value * 6895 / 1000;
} else if (format::pressure_unit == PRESSURE_UNIT_BAR) {
pressure_kpa_ = value * 100;
} else {
pressure_kpa_ = value;
}
update_packet_display();
};
field_temperature.on_change = [this](int32_t value) {
// Convert from displayed unit to Celsius for internal storage
if (format::temp_unit == TEMP_UNIT_FAHRENHEIT) {
temperature_c_ = (value - 32) * 5 / 9;
} else {
temperature_c_ = value;
}
update_packet_display();
};
field_flags.on_change = [this](int32_t value) {
flags_ = value & 0x07;
update_packet_display();
};
field_repeat.on_change = [this](int32_t value) {
repeat_count_ = value;
};
button_transmit.on_select = [this](Button&) {
if (is_transmitting_) {
stop_tx();
} else {
start_tx();
}
};
button_load.on_select = [this, &nav](Button&) {
auto open_view = nav.push<FileLoadView>(".TXT");
open_view->on_changed = [this](std::filesystem::path file_path) {
// Load TPMS packet from file
File f;
auto error = f.open(file_path);
if (!error.is_valid()) {
char buffer[512];
auto result = f.read(buffer, sizeof(buffer) - 1);
if (result.is_ok()) {
buffer[result.value()] = 0;
// Parse the file format (key=value format, one per line)
std::string content(buffer);
size_t pos = 0;
auto parse_line = [&content, &pos](const std::string& key) -> std::string {
size_t key_pos = content.find(key + "=", pos);
if (key_pos != std::string::npos) {
size_t value_start = key_pos + key.length() + 1;
size_t value_end = content.find('\n', value_start);
if (value_end == std::string::npos) value_end = content.length();
return content.substr(value_start, value_end - value_start);
}
return "";
};
std::string type_str = parse_line("Type");
std::string id_str = parse_line("ID");
std::string pressure_str = parse_line("Pressure");
std::string temp_str = parse_line("Temperature");
std::string flags_str = parse_line("Flags");
std::string signal_type_str = parse_line("SignalType");
if (!type_str.empty()) {
int type = std::stoi(type_str);
if (type >= static_cast<int>(tpms::Reading::Type::FLM_64) &&
type <= static_cast<int>(tpms::Reading::Type::GMC_96)) {
packet_type_ = static_cast<tpms::Reading::Type>(type);
options_packet_type.set_by_value(type);
}
}
if (!id_str.empty()) {
transponder_id_ = std::stoul(id_str, nullptr, 16);
field_transponder_id_24.set_value(transponder_id_ & 0x00FFFFFF);
field_transponder_id_32.set_value(transponder_id_);
}
if (!pressure_str.empty()) {
pressure_kpa_ = std::stoi(pressure_str);
on_pressure_unit_change();
}
if (!temp_str.empty()) {
temperature_c_ = std::stoi(temp_str);
field_temperature.set_value(temperature_c_);
}
if (!flags_str.empty()) {
uint8_t loaded_flags = std::stoul(flags_str, nullptr, 16);
// Handle old format files: extract bits 6-4 if value > 7
flags_ = (loaded_flags > 7) ? ((loaded_flags >> 4) & 0x07) : (loaded_flags & 0x07);
field_flags.set_value(flags_);
}
// Load signal type if available, otherwise auto-select based on packet type
if (!signal_type_str.empty()) {
int signal_type = std::stoi(signal_type_str);
if (signal_type >= 1 && signal_type <= 3) {
signal_type_ = static_cast<tpms::SignalType>(signal_type);
} else {
// Fall back to auto-detect
update_signal_type_from_packet();
}
} else {
// Fall back to auto-detect for backwards compatibility
update_signal_type_from_packet();
}
update_packet_display();
update_field_visibility();
text_status.set("Loaded: " + file_path.filename().string());
} else {
text_status.set("Error reading file");
}
} else {
text_status.set("Error opening file");
}
};
};
button_save.on_select = [this](Button&) {
// Save current TPMS packet to file
auto timestamp = to_string_timestamp(rtc_time::now());
std::string file_name = "TPMS_" + timestamp + ".TXT";
ensure_directory(tpms_dir);
auto file_path = tpms_dir / file_name;
File f;
auto error = f.create(file_path);
if (!error.is_valid()) {
std::string content = "Type=" + to_string_dec_uint(toUType(packet_type_), 1) + "\n";
content += "ID=" + to_string_hex(transponder_id_, 8) + "\n";
content += "Pressure=" + to_string_dec_uint(pressure_kpa_) + "\n";
content += "Temperature=" + to_string_dec_int(temperature_c_) + "\n";
content += "Flags=" + to_string_hex(flags_ & 0x07, 1) + "\n";
f.write(content.c_str(), content.length());
text_status.set("Saved: " + file_name);
} else {
text_status.set("Error saving file");
}
};
}
TPMSTXView::~TPMSTXView() {
stop_tx();
transmitter_model.disable();
baseband::shutdown();
}
} // namespace ui::external_app::tpmstx
+226
View File
@@ -0,0 +1,226 @@
/*
* Copyright (C) 2026
*
* 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.
*/
#ifndef __TPMS_TX_APP_H__
#define __TPMS_TX_APP_H__
#include "ui_widget.hpp"
#include "ui_navigation.hpp"
#include "ui_transmitter.hpp"
#include "transmitter_model.hpp"
#include "app_settings.hpp"
#include "radio_state.hpp"
#include "portapack.hpp"
#include "message.hpp"
#include "tpms_packet.hpp"
#include "file_path.hpp"
#include "string_format.hpp"
#include "units.hpp"
namespace ui::external_app::tpmstx {
namespace format {
static uint8_t pressure_unit{PRESSURE_UNIT_PSI};
static uint8_t temp_unit{TEMP_UNIT_CELSIUS};
} // namespace format
class TPMSTXView : public View {
public:
TPMSTXView(NavigationView& nav);
~TPMSTXView();
void focus() override;
std::string title() const override { return "TPMS TX"; };
private:
NavigationView& nav_;
TxRadioState radio_state_{
314900000, /* frequency */
1750000, /* bandwidth */
2457600 /* sampling rate */
};
app_settings::SettingsManager settings_{
"tx_tpms",
app_settings::Mode::TX,
{
{"pressure_unit"sv, &format::pressure_unit},
{"temp_unit"sv, &format::temp_unit},
}};
// TPMS packet data
tpms::Reading::Type packet_type_{tpms::Reading::Type::Schrader};
uint32_t transponder_id_{0x12345678};
uint16_t pressure_kpa_{240}; // Default ~35 PSI
int16_t temperature_c_{25}; // Default 25°C
uint8_t flags_{0x00}; // 3-bit function code (0-7), checksum is auto-calculated
tpms::SignalType signal_type_{tpms::SignalType::FSK_19k2_Schrader};
uint8_t repeat_count_{5};
uint32_t pause_duration_{50}; // ms between repeats
bool is_transmitting_{false};
// FSK-specific repeat tracking (OOK repeats are handled by baseband)
uint8_t fsk_repeat_counter_{0};
uint32_t fsk_repeat_timer_id_{0};
void update_signal_type_from_packet();
void switch_baseband();
void start_tx();
void stop_tx();
void send_packet();
void encode_and_transmit();
void update_packet_display();
void on_pressure_unit_change();
void on_temperature_unit_change();
void update_field_visibility();
MessageHandlerRegistration message_handler_tx_progress{
Message::ID::TXProgress,
[this](const Message* const p) {
const auto message = *reinterpret_cast<const TXProgressMessage*>(p);
progressbar.set_value(message.progress);
if (message.done) {
handle_tx_complete();
}
}};
void handle_tx_complete();
Labels labels{
{{0 * 8, 0 * 16}, "Freq:", Theme::getInstance()->fg_light->foreground},
{{0 * 8, 1 * 16}, "Type:", Theme::getInstance()->fg_light->foreground},
{{0 * 8, 2 * 16}, "ID:", Theme::getInstance()->fg_light->foreground},
{{0 * 8, 3 * 16}, "Pres:", Theme::getInstance()->fg_light->foreground},
{{0 * 8, 5 * 16}, "Rpt:", Theme::getInstance()->fg_light->foreground},
};
Text label_temperature{
{0 * 8, 4 * 16, 5 * 8, 16},
"Temp:"};
Text label_flags{
{0 * 8, 4 * 16, 5 * 8, 16},
"Func:"};
OptionsField options_frequency{
{6 * 8, 0 * 16},
5,
{
{"314.9", 314900000},
{"315.0", 315000000},
{"433.9", 433920000},
}};
OptionsField options_packet_type{
{6 * 8, 1 * 16},
10,
{
{"Schrader", (int32_t)tpms::Reading::Type::Schrader},
{"FLM_64", (int32_t)tpms::Reading::Type::FLM_64},
{"FLM_72", (int32_t)tpms::Reading::Type::FLM_72},
{"FLM_80", (int32_t)tpms::Reading::Type::FLM_80},
{"GMC_96", (int32_t)tpms::Reading::Type::GMC_96},
}};
OptionsField options_pressure{
{11 * 8, 3 * 16},
4,
{{"kPa", PRESSURE_UNIT_KPA},
{"PSI", PRESSURE_UNIT_PSI},
{"BAR", PRESSURE_UNIT_BAR}}};
OptionsField options_temperature{
{11 * 8, 4 * 16},
2,
{{STR_DEGREES_C, TEMP_UNIT_CELSIUS},
{STR_DEGREES_F, TEMP_UNIT_FAHRENHEIT}}};
SymField field_transponder_id_24{
{6 * 8, 2 * 16},
6,
SymField::Type::Hex};
SymField field_transponder_id_32{
{6 * 8, 2 * 16},
8,
SymField::Type::Hex};
NumberField field_pressure{
{6 * 8, 3 * 16},
4,
{0, 9999},
1,
' '};
NumberField field_temperature{
{6 * 8, 4 * 16},
4,
{-99, 999},
1,
' '};
NumberField field_flags{
{6 * 8, 4 * 16},
1,
{0, 7},
1,
' '};
NumberField field_repeat{
{6 * 8, 5 * 16},
3,
{1, 100},
1,
' '};
Button button_load{
{0 * 8, 8 * 16 + 4, 7 * 8, 24},
"Load"};
Button button_save{
{8 * 8, 8 * 16 + 4, 7 * 8, 24},
"Save"};
TransmitterView2 tx_view{
{16 * 8, 0 * 16},
true // Short UI format
};
Button button_transmit{
{0 * 8, 11 * 16, 15 * 8, 32},
"START TX"};
Text text_status{
{0 * 8, 13 * 16 + 4, 30 * 8, 16},
"Ready"};
ProgressBar progressbar{
{0 * 8, 14 * 16 + 8, 30 * 8, 16}};
};
} // namespace ui::external_app::tpmstx
#endif /*__TPMS_TX_APP_H__*/
+1
View File
@@ -51,6 +51,7 @@ const std::filesystem::path whipcalc_dir = u"WHIPCALC";
const std::filesystem::path ook_editor_dir = u"OOKFILES";
const std::filesystem::path hopper_dir = u"HOPPER";
const std::filesystem::path subghz_dir = u"SUBGHZ";
const std::filesystem::path tpms_dir = u"TPMS";
const std::filesystem::path waterfalls_dir = u"WATERFALLS";
const std::filesystem::path macaddress_dir = u"MACADDRESS";
const std::filesystem::path splash_dot_bmp = u"/splash.bmp";
+1
View File
@@ -53,6 +53,7 @@ extern const std::filesystem::path whipcalc_dir;
extern const std::filesystem::path ook_editor_dir;
extern const std::filesystem::path hopper_dir;
extern const std::filesystem::path subghz_dir;
extern const std::filesystem::path tpms_dir;
extern const std::filesystem::path waterfalls_dir;
extern const std::filesystem::path macaddress_dir;
extern const std::filesystem::path keeloq_keys_dir;
+1 -1
View File
@@ -24,4 +24,4 @@
# external app address ranges below must match those in linker file "external.ld"
maximum_application_size = 32*1024
external_apps_address_start = 0xADB00000
external_apps_address_end = 0xADF00000
external_apps_address_end = 0xADFC0000