mirror of
https://github.com/RFnexus/modem73.git
synced 2026-04-27 14:30:33 +00:00
dev -> master
- Fix double framing in queue_data - check if TCP port is available before binding - show headers in verbose output - Add packet fragmentation/reassembly with magic byte 0xF3 - Fragmentation enabled via --frag flag or UI toggle - Update utility `update.sh` Fragment header: `[MAGIC][PKT_ID:2][SEQ][FLAGS]`
This commit is contained in:
commit
2b67455f5c
5 changed files with 678 additions and 61 deletions
|
|
@ -98,4 +98,11 @@ while running `rigctld`
|
|||
```
|
||||
|
||||
|
||||
## Updating
|
||||
|
||||
modem73 comes included with a update utility `update.sh`
|
||||
|
||||
To update to the latest version:
|
||||
```
|
||||
./update.sh
|
||||
```
|
||||
212
kiss_tnc.cc
212
kiss_tnc.cc
|
|
@ -67,6 +67,27 @@ inline void ui_log(const std::string& msg) {
|
|||
}
|
||||
}
|
||||
|
||||
bool check_port_available(const std::string& bind_address, int port) {
|
||||
int sock = socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (sock < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int opt = 1;
|
||||
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
|
||||
|
||||
struct sockaddr_in addr;
|
||||
memset(&addr, 0, sizeof(addr));
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_addr.s_addr = inet_addr(bind_address.c_str());
|
||||
addr.sin_port = htons(port);
|
||||
|
||||
int result = bind(sock, (struct sockaddr*)&addr, sizeof(addr));
|
||||
close(sock);
|
||||
|
||||
return result == 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -208,6 +229,8 @@ public:
|
|||
std::cerr << "CSMA: disabled" << std::endl;
|
||||
}
|
||||
|
||||
std::cerr << "Fragmentation: " << (config_.fragmentation_enabled ? "enabled" : "disabled") << std::endl;
|
||||
|
||||
// Show PTT status
|
||||
switch (config_.ptt_type) {
|
||||
case PTTType::NONE:
|
||||
|
|
@ -321,47 +344,83 @@ private:
|
|||
void handle_kiss_frame(uint8_t /*port*/, uint8_t cmd, const std::vector<uint8_t>& data) {
|
||||
if (cmd == KISS::CMD_DATA) {
|
||||
if (g_verbose) {
|
||||
hex_dump("TX Queue", data.data(), data.size());
|
||||
std::cerr << kiss_frame_visualize(data.data(), data.size()) << std::endl;
|
||||
}
|
||||
|
||||
// Check size
|
||||
if (data.size() > (size_t)payload_size_) {
|
||||
std::cerr << "Warning: Frame too large (" << data.size()
|
||||
<< " > " << payload_size_ << "), truncating" << std::endl;
|
||||
}
|
||||
size_t max_payload = payload_size_ - 2;
|
||||
|
||||
tx_queue_.push(data);
|
||||
if (config_.fragmentation_enabled && fragmenter_.needs_fragmentation(data.size(), max_payload)) {
|
||||
auto fragments = fragmenter_.fragment(data, max_payload);
|
||||
ui_log("TX: Fragmenting " + std::to_string(data.size()) + " bytes into " +
|
||||
std::to_string(fragments.size()) + " fragments");
|
||||
for (auto& frag : fragments) {
|
||||
if (g_verbose) {
|
||||
std::cerr << packet_visualize(frag.data(), frag.size(), true, true) << std::endl;
|
||||
}
|
||||
tx_queue_.push(std::move(frag));
|
||||
}
|
||||
#ifdef WITH_UI
|
||||
if (g_ui_state) {
|
||||
g_ui_state->tx_queue_size = tx_queue_.size();
|
||||
}
|
||||
if (g_ui_state) {
|
||||
g_ui_state->tx_queue_size = tx_queue_.size();
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
std::vector<uint8_t> frame_data = data;
|
||||
if (frame_data.size() > max_payload) {
|
||||
std::cerr << "Warning: Frame too large (" << frame_data.size()
|
||||
<< " > " << max_payload << "), truncating" << std::endl;
|
||||
frame_data.resize(max_payload);
|
||||
}
|
||||
if (g_verbose) {
|
||||
std::cerr << packet_visualize(frame_data.data(), frame_data.size(), true, config_.fragmentation_enabled) << std::endl;
|
||||
}
|
||||
tx_queue_.push(frame_data);
|
||||
#ifdef WITH_UI
|
||||
if (g_ui_state) {
|
||||
g_ui_state->tx_queue_size = tx_queue_.size();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
} else {
|
||||
// Handle KISS control commands
|
||||
switch (cmd) {
|
||||
case KISS::CMD_TXDELAY:
|
||||
if (!data.empty()) {
|
||||
config_.tx_delay_ms = data[0] * 10;
|
||||
std::cerr << "TXDelay set to " << config_.tx_delay_ms << " ms" << std::endl;
|
||||
ui_log("TXDelay set to " + std::to_string(config_.tx_delay_ms) + " ms");
|
||||
}
|
||||
break;
|
||||
case KISS::CMD_P:
|
||||
if (!data.empty()) {
|
||||
config_.p_persistence = data[0];
|
||||
ui_log("P-persistence set to " + std::to_string(config_.p_persistence));
|
||||
}
|
||||
break;
|
||||
case KISS::CMD_SLOTTIME:
|
||||
if (!data.empty()) {
|
||||
config_.slot_time_ms = data[0] * 10;
|
||||
ui_log("Slot time set to " + std::to_string(config_.slot_time_ms) + " ms");
|
||||
}
|
||||
break;
|
||||
case KISS::CMD_TXTAIL:
|
||||
if (!data.empty()) {
|
||||
config_.ptt_tail_ms = data[0] * 10;
|
||||
ui_log("TXTail set to " + std::to_string(config_.ptt_tail_ms) + " ms");
|
||||
}
|
||||
break;
|
||||
case KISS::CMD_FULLDUPLEX:
|
||||
if (!data.empty()) {
|
||||
config_.full_duplex = data[0] != 0;
|
||||
ui_log(std::string("Full duplex ") + (config_.full_duplex ? "enabled" : "disabled"));
|
||||
}
|
||||
break;
|
||||
case KISS::CMD_SETHW:
|
||||
break;
|
||||
case KISS::CMD_RETURN:
|
||||
break;
|
||||
default:
|
||||
std::cerr << "Unknown KISS command: " << (int)cmd << std::endl;
|
||||
if (g_verbose) {
|
||||
std::cerr << "Unknown KISS command: 0x" << std::hex << (int)cmd << std::dec << std::endl;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -444,7 +503,7 @@ private:
|
|||
void transmit(const std::vector<uint8_t>& data) {
|
||||
ui_log("TX: " + std::to_string(data.size()) + " bytes");
|
||||
if (g_verbose) {
|
||||
hex_dump("TX", data.data(), data.size());
|
||||
std::cerr << packet_visualize(data.data(), data.size(), true, config_.fragmentation_enabled) << std::endl;
|
||||
}
|
||||
|
||||
#ifdef WITH_UI
|
||||
|
|
@ -593,9 +652,30 @@ private:
|
|||
|
||||
std::vector<float> buffer(1024);
|
||||
int level_update_counter = 0;
|
||||
const int LEVEL_UPDATE_INTERVAL = 5; // Update level every N reads
|
||||
const int LEVEL_UPDATE_INTERVAL = 5;
|
||||
|
||||
auto frame_callback = [this](const uint8_t* data, size_t len) {
|
||||
auto deliver_to_clients = [this](const std::vector<uint8_t>& payload, float snr, bool was_reassembled) {
|
||||
ui_log("RX: " + std::to_string(payload.size()) + " bytes, SNR=" +
|
||||
std::to_string((int)snr) + "dB" + (was_reassembled ? " (reassembled)" : ""));
|
||||
if (g_verbose) {
|
||||
std::cerr << packet_visualize(payload.data(), payload.size(), false, false) << std::endl;
|
||||
}
|
||||
|
||||
#ifdef WITH_UI
|
||||
if (g_ui_state) {
|
||||
g_ui_state->add_packet(false, payload.size(), snr);
|
||||
}
|
||||
#endif
|
||||
|
||||
auto kiss_frame = KISSParser::wrap(payload);
|
||||
|
||||
std::lock_guard<std::mutex> lock(clients_mutex_);
|
||||
for (auto& client : clients_) {
|
||||
client->send(kiss_frame);
|
||||
}
|
||||
};
|
||||
|
||||
auto frame_callback = [this, &deliver_to_clients](const uint8_t* data, size_t len) {
|
||||
set_tx_lockout(RX_LOCKOUT_SECONDS);
|
||||
|
||||
float snr = decoder_->get_last_snr();
|
||||
|
|
@ -608,7 +688,6 @@ private:
|
|||
}
|
||||
#endif
|
||||
|
||||
// Strip length prefix framing
|
||||
auto payload = unframe_length(data, len);
|
||||
|
||||
if (payload.empty()) {
|
||||
|
|
@ -619,25 +698,18 @@ private:
|
|||
return;
|
||||
}
|
||||
|
||||
ui_log("RX: " + std::to_string(payload.size()) + " bytes, SNR=" +
|
||||
std::to_string((int)snr) + "dB");
|
||||
if (g_verbose) {
|
||||
hex_dump("RX", payload.data(), payload.size());
|
||||
}
|
||||
|
||||
#ifdef WITH_UI
|
||||
// Track packet for UI display
|
||||
if (g_ui_state) {
|
||||
g_ui_state->add_packet(false, payload.size(), snr);
|
||||
}
|
||||
#endif
|
||||
|
||||
// Send to all clients
|
||||
auto kiss_frame = KISSParser::wrap(payload);
|
||||
|
||||
std::lock_guard<std::mutex> lock(clients_mutex_);
|
||||
for (auto& client : clients_) {
|
||||
client->send(kiss_frame);
|
||||
if (config_.fragmentation_enabled && reassembler_.is_fragment(payload)) {
|
||||
if (g_verbose) {
|
||||
std::cerr << packet_visualize(payload.data(), payload.size(), false, true) << std::endl;
|
||||
}
|
||||
|
||||
auto reassembled = reassembler_.process(payload);
|
||||
if (!reassembled.empty()) {
|
||||
ui_log("RX: Reassembled " + std::to_string(reassembled.size()) + " bytes from fragments");
|
||||
deliver_to_clients(reassembled, snr, true);
|
||||
}
|
||||
} else {
|
||||
deliver_to_clients(payload, snr, false);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -737,10 +809,13 @@ private:
|
|||
std::atomic<bool> tx_running_{false};
|
||||
std::atomic<bool> rx_running_{false};
|
||||
|
||||
Fragmenter fragmenter_;
|
||||
Reassembler reassembler_;
|
||||
|
||||
// TX lockout - prevents TX while receiving
|
||||
std::mutex lockout_mutex_;
|
||||
std::chrono::steady_clock::time_point tx_lockout_until_;
|
||||
static constexpr float RX_LOCKOUT_SECONDS = 0.5f; // Post-RX settling time
|
||||
static constexpr float RX_LOCKOUT_SECONDS = 0.5f;
|
||||
|
||||
public:
|
||||
// Update config at runtime (called from UI)
|
||||
|
|
@ -810,10 +885,19 @@ public:
|
|||
return false;
|
||||
}
|
||||
|
||||
// Queue data for transmission
|
||||
void queue_data(const std::vector<uint8_t>& data) {
|
||||
auto framed = frame_with_length(data);
|
||||
tx_queue_.push(framed);
|
||||
size_t max_payload = payload_size_ - 2;
|
||||
|
||||
if (config_.fragmentation_enabled && fragmenter_.needs_fragmentation(data.size(), max_payload)) {
|
||||
auto fragments = fragmenter_.fragment(data, max_payload);
|
||||
ui_log("TX: Fragmenting " + std::to_string(data.size()) + " bytes into " +
|
||||
std::to_string(fragments.size()) + " fragments");
|
||||
for (auto& frag : fragments) {
|
||||
tx_queue_.push(std::move(frag));
|
||||
}
|
||||
} else {
|
||||
tx_queue_.push(data);
|
||||
}
|
||||
#ifdef WITH_UI
|
||||
if (g_ui_state) {
|
||||
g_ui_state->tx_queue_size = tx_queue_.size();
|
||||
|
|
@ -850,6 +934,9 @@ void print_help(const char* prog) {
|
|||
<< " --csma-threshold DB Carrier sense threshold (default: -30)\n"
|
||||
<< " --csma-slot MS Slot time in ms (default: 500)\n"
|
||||
<< " --csma-persist N P-persistence 0-255 (default: 128 = 50%)\n"
|
||||
<< "\nFragmentation:\n"
|
||||
<< " --frag Enable packet fragmentation/reassembly\n"
|
||||
<< " --no-frag Disable fragmentation (default)\n"
|
||||
<< "\n"
|
||||
#ifdef WITH_UI
|
||||
<< " -h, --headless Run without TUI\n"
|
||||
|
|
@ -948,6 +1035,10 @@ int main(int argc, char** argv) {
|
|||
config.slot_time_ms = std::atoi(argv[++i]);
|
||||
} else if (arg == "--csma-persist" && i + 1 < argc) {
|
||||
config.p_persistence = std::atoi(argv[++i]);
|
||||
} else if (arg == "--frag") {
|
||||
config.fragmentation_enabled = true;
|
||||
} else if (arg == "--no-frag") {
|
||||
config.fragmentation_enabled = false;
|
||||
} else {
|
||||
std::cerr << "Unknown option: " << arg << std::endl;
|
||||
print_help(argv[0]);
|
||||
|
|
@ -1005,6 +1096,7 @@ int main(int argc, char** argv) {
|
|||
config.carrier_threshold_db = ui_state.carrier_threshold_db;
|
||||
config.slot_time_ms = ui_state.slot_time_ms;
|
||||
config.p_persistence = ui_state.p_persistence;
|
||||
config.fragmentation_enabled = ui_state.fragmentation_enabled;
|
||||
// Audio devices
|
||||
config.audio_input_device = ui_state.audio_input_device;
|
||||
config.audio_output_device = ui_state.audio_output_device;
|
||||
|
|
@ -1048,6 +1140,7 @@ int main(int argc, char** argv) {
|
|||
ui_state.slot_time_ms = config.slot_time_ms;
|
||||
ui_state.p_persistence = config.p_persistence;
|
||||
ui_state.short_frame = config.short_frame;
|
||||
ui_state.fragmentation_enabled = config.fragmentation_enabled;
|
||||
// Audio devices
|
||||
ui_state.audio_input_device = config.audio_input_device;
|
||||
ui_state.audio_output_device = config.audio_output_device;
|
||||
|
|
@ -1099,8 +1192,8 @@ int main(int argc, char** argv) {
|
|||
|
||||
ui_state.load_presets();
|
||||
|
||||
|
||||
|
||||
// Sync fragmentation setting from command line to UI
|
||||
ui_state.fragmentation_enabled = config.fragmentation_enabled;
|
||||
|
||||
ui_state.update_modem_info();
|
||||
|
||||
|
|
@ -1111,6 +1204,40 @@ int main(int argc, char** argv) {
|
|||
}
|
||||
#endif
|
||||
|
||||
while (!check_port_available(config.bind_address, config.port)) {
|
||||
std::cerr << "Error: Port " << config.port << " is already in use or cannot be bound" << std::endl;
|
||||
std::cerr << "Another instance of modem73 may be running, or another application is using this port." << std::endl;
|
||||
|
||||
if (!g_use_ui) {
|
||||
std::cerr << "Use --port to specify a different port." << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::cerr << "\nEnter a different port number (or 'q' to quit): ";
|
||||
std::string input;
|
||||
if (!std::getline(std::cin, input) || input.empty() || input == "q" || input == "Q") {
|
||||
std::cerr << "Exiting." << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
try {
|
||||
int new_port = std::stoi(input);
|
||||
if (new_port < 1 || new_port > 65535) {
|
||||
std::cerr << "Invalid port number. Must be between 1 and 65535." << std::endl;
|
||||
continue;
|
||||
}
|
||||
config.port = new_port;
|
||||
#ifdef WITH_UI
|
||||
if (g_use_ui) {
|
||||
ui_state.port = new_port;
|
||||
}
|
||||
#endif
|
||||
std::cerr << "Trying port " << config.port << "..." << std::endl;
|
||||
} catch (const std::exception&) {
|
||||
std::cerr << "Invalid input. Please enter a number." << std::endl;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
KISSTNC tnc(config);
|
||||
|
||||
|
|
@ -1127,6 +1254,7 @@ int main(int argc, char** argv) {
|
|||
new_config.carrier_threshold_db = state.carrier_threshold_db;
|
||||
new_config.p_persistence = state.p_persistence;
|
||||
new_config.slot_time_ms = state.slot_time_ms;
|
||||
new_config.fragmentation_enabled = state.fragmentation_enabled;
|
||||
// Audio devices
|
||||
new_config.audio_input_device = state.audio_input_device;
|
||||
new_config.audio_output_device = state.audio_output_device;
|
||||
|
|
|
|||
312
kiss_tnc.hh
312
kiss_tnc.hh
|
|
@ -3,10 +3,13 @@
|
|||
#include <cstdint>
|
||||
#include <vector>
|
||||
#include <queue>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <sstream>
|
||||
#include <iomanip>
|
||||
#include <iostream>
|
||||
|
||||
|
|
@ -83,9 +86,12 @@ struct TNCConfig {
|
|||
|
||||
// CSMA settings
|
||||
bool csma_enabled = true;
|
||||
float carrier_threshold_db = -30.0f; // Carrier sense threshold
|
||||
int carrier_sense_ms = 100; // How long to listen
|
||||
int max_backoff_slots = 10; // Maximum backoff
|
||||
float carrier_threshold_db = -30.0f;
|
||||
int carrier_sense_ms = 100;
|
||||
int max_backoff_slots = 10;
|
||||
|
||||
// Fragmentation settings
|
||||
bool fragmentation_enabled = false;
|
||||
|
||||
// Settings file path
|
||||
std::string config_file = "";
|
||||
|
|
@ -129,7 +135,6 @@ private:
|
|||
void process_byte(uint8_t byte) {
|
||||
if (byte == KISS::FEND) {
|
||||
if (in_frame_ && buffer_.size() > 0) {
|
||||
// frame complete
|
||||
uint8_t cmd_byte = buffer_[0];
|
||||
uint8_t port = (cmd_byte >> 4) & 0x0F;
|
||||
uint8_t cmd = cmd_byte & 0x0F;
|
||||
|
|
@ -206,7 +211,6 @@ inline void hex_dump(const char* prefix, const uint8_t* data, size_t len) {
|
|||
for (size_t i = 0; i < len; i += 16) {
|
||||
std::cerr << " " << std::hex << std::setfill('0') << std::setw(4) << i << ": ";
|
||||
|
||||
// Hex
|
||||
for (size_t j = 0; j < 16 && i + j < len; ++j) {
|
||||
std::cerr << std::setw(2) << (int)data[i + j] << " ";
|
||||
}
|
||||
|
|
@ -214,7 +218,6 @@ inline void hex_dump(const char* prefix, const uint8_t* data, size_t len) {
|
|||
std::cerr << " ";
|
||||
}
|
||||
|
||||
// ASCII
|
||||
std::cerr << " |";
|
||||
for (size_t j = 0; j < 16 && i + j < len; ++j) {
|
||||
char c = data[i + j];
|
||||
|
|
@ -225,6 +228,135 @@ inline void hex_dump(const char* prefix, const uint8_t* data, size_t len) {
|
|||
std::cerr << std::dec;
|
||||
}
|
||||
|
||||
inline std::string packet_visualize(const uint8_t* data, size_t len, bool is_tx, bool frag_enabled) {
|
||||
std::ostringstream oss;
|
||||
|
||||
if (len == 0) {
|
||||
oss << " [EMPTY PACKET]";
|
||||
return oss.str();
|
||||
}
|
||||
|
||||
oss << "\n ┌─────────────────────────────────────────────────────────────┐\n";
|
||||
oss << " │ " << (is_tx ? "TX" : "RX") << " PACKET: " << len << " bytes";
|
||||
oss << std::string(47 - std::to_string(len).length(), ' ') << "│\n";
|
||||
oss << " ├─────────────────────────────────────────────────────────────┤\n";
|
||||
|
||||
size_t offset = 0;
|
||||
|
||||
// Check for fragment by magic byte
|
||||
if (frag_enabled && len >= 5 && data[0] == 0xF3) {
|
||||
uint16_t pkt_id = (data[1] << 8) | data[2];
|
||||
uint8_t seq = data[3];
|
||||
uint8_t flags = data[4];
|
||||
|
||||
oss << " │ FRAG HDR [5 bytes] Magic: 0xF3 │\n";
|
||||
oss << " │ Packet ID: " << std::setw(5) << pkt_id;
|
||||
oss << " Seq: " << std::setw(3) << (int)seq;
|
||||
oss << " Flags: ";
|
||||
|
||||
std::string flag_str;
|
||||
if (flags & 0x02) flag_str += "FIRST ";
|
||||
if (flags & 0x01) flag_str += "MORE";
|
||||
if (flag_str.empty()) flag_str = "LAST";
|
||||
oss << std::left << std::setw(12) << flag_str << std::right << " │\n";
|
||||
|
||||
offset = 5;
|
||||
oss << " ├─────────────────────────────────────────────────────────────┤\n";
|
||||
}
|
||||
|
||||
size_t payload_len = len - offset;
|
||||
oss << " │ PAYLOAD [" << payload_len << " bytes]";
|
||||
oss << std::string(49 - std::to_string(payload_len).length(), ' ') << "│\n";
|
||||
|
||||
size_t preview_len = std::min(payload_len, (size_t)32);
|
||||
if (preview_len > 0) {
|
||||
oss << " │ ";
|
||||
for (size_t i = 0; i < preview_len; i++) {
|
||||
oss << std::hex << std::setfill('0') << std::setw(2) << (int)data[offset + i];
|
||||
if (i < preview_len - 1) oss << " ";
|
||||
}
|
||||
if (payload_len > 32) oss << "...";
|
||||
size_t used = preview_len * 3 - 1 + (payload_len > 32 ? 3 : 0);
|
||||
if (used < 57) oss << std::string(57 - used, ' ');
|
||||
oss << std::dec << " │\n";
|
||||
|
||||
oss << " │ ";
|
||||
for (size_t i = 0; i < preview_len; i++) {
|
||||
char c = data[offset + i];
|
||||
oss << (c >= 32 && c < 127 ? c : '.');
|
||||
}
|
||||
if (payload_len > 32) oss << "...";
|
||||
size_t ascii_used = preview_len + (payload_len > 32 ? 3 : 0);
|
||||
if (ascii_used < 57) oss << std::string(57 - ascii_used, ' ');
|
||||
oss << " │\n";
|
||||
}
|
||||
|
||||
oss << " └─────────────────────────────────────────────────────────────┘";
|
||||
|
||||
return oss.str();
|
||||
}
|
||||
|
||||
inline std::string kiss_frame_visualize(const uint8_t* data, size_t len) {
|
||||
std::ostringstream oss;
|
||||
|
||||
if (len == 0) {
|
||||
oss << " [EMPTY KISS FRAME]";
|
||||
return oss.str();
|
||||
}
|
||||
|
||||
oss << "\n ┌─────────────────────────────────────────────────────────────┐\n";
|
||||
oss << " │ KISS FRAME: " << len << " bytes";
|
||||
oss << std::string(45 - std::to_string(len).length(), ' ') << "│\n";
|
||||
oss << " ├─────────────────────────────────────────────────────────────┤\n";
|
||||
|
||||
if (len >= 1) {
|
||||
uint8_t cmd_byte = data[0];
|
||||
uint8_t port = (cmd_byte >> 4) & 0x0F;
|
||||
uint8_t cmd = cmd_byte & 0x0F;
|
||||
|
||||
oss << " │ CMD BYTE: 0x" << std::hex << std::setfill('0') << std::setw(2) << (int)cmd_byte << std::dec;
|
||||
oss << " Port: " << (int)port << " Cmd: ";
|
||||
|
||||
std::string cmd_name;
|
||||
switch (cmd) {
|
||||
case 0x00: cmd_name = "DATA"; break;
|
||||
case 0x01: cmd_name = "TXDELAY"; break;
|
||||
case 0x02: cmd_name = "P"; break;
|
||||
case 0x03: cmd_name = "SLOTTIME"; break;
|
||||
case 0x04: cmd_name = "TXTAIL"; break;
|
||||
case 0x05: cmd_name = "FULLDUPLEX"; break;
|
||||
case 0x06: cmd_name = "SETHW"; break;
|
||||
case 0x0F: cmd_name = "RETURN"; break;
|
||||
default: cmd_name = "UNKNOWN"; break;
|
||||
}
|
||||
oss << std::left << std::setw(10) << cmd_name << std::right;
|
||||
oss << " │\n";
|
||||
}
|
||||
|
||||
if (len > 1) {
|
||||
oss << " ├─────────────────────────────────────────────────────────────┤\n";
|
||||
size_t payload_len = len - 1;
|
||||
oss << " │ PAYLOAD [" << payload_len << " bytes]";
|
||||
oss << std::string(49 - std::to_string(payload_len).length(), ' ') << "│\n";
|
||||
|
||||
size_t preview_len = std::min(payload_len, (size_t)24);
|
||||
oss << " │ ";
|
||||
for (size_t i = 0; i < preview_len; i++) {
|
||||
oss << std::hex << std::setfill('0') << std::setw(2) << (int)data[1 + i];
|
||||
if (i < preview_len - 1) oss << " ";
|
||||
}
|
||||
if (payload_len > 24) oss << " ...";
|
||||
oss << std::dec;
|
||||
size_t used = preview_len * 3 - 1 + (payload_len > 24 ? 4 : 0);
|
||||
if (used < 57) oss << std::string(57 - used, ' ');
|
||||
oss << " │\n";
|
||||
}
|
||||
|
||||
oss << " └─────────────────────────────────────────────────────────────┘";
|
||||
|
||||
return oss.str();
|
||||
}
|
||||
|
||||
// Length-prefix framing
|
||||
// This handles OFDM frame padding where the payload is encoded within the 2 byte prefix
|
||||
inline std::vector<uint8_t> frame_with_length(const std::vector<uint8_t>& data) {
|
||||
|
|
@ -246,3 +378,171 @@ inline std::vector<uint8_t> unframe_length(const uint8_t* data, size_t total_len
|
|||
}
|
||||
return std::vector<uint8_t>(data + 2, data + 2 + payload_len);
|
||||
}
|
||||
|
||||
namespace Frag {
|
||||
constexpr uint8_t MAGIC = 0xF3;
|
||||
constexpr size_t HEADER_SIZE = 5;
|
||||
constexpr uint8_t FLAG_MORE_FRAGMENTS = 0x01;
|
||||
constexpr uint8_t FLAG_FIRST_FRAGMENT = 0x02;
|
||||
constexpr int REASSEMBLY_TIMEOUT_MS = 30000;
|
||||
constexpr size_t MAX_PENDING_PACKETS = 64;
|
||||
}
|
||||
|
||||
class Fragmenter {
|
||||
public:
|
||||
Fragmenter() : next_packet_id_(0) {}
|
||||
|
||||
std::vector<std::vector<uint8_t>> fragment(const std::vector<uint8_t>& data, size_t max_payload) {
|
||||
std::vector<std::vector<uint8_t>> fragments;
|
||||
|
||||
if (max_payload <= Frag::HEADER_SIZE) {
|
||||
return fragments;
|
||||
}
|
||||
|
||||
size_t data_per_frag = max_payload - Frag::HEADER_SIZE;
|
||||
size_t num_frags = (data.size() + data_per_frag - 1) / data_per_frag;
|
||||
if (num_frags > 255) {
|
||||
num_frags = 255;
|
||||
}
|
||||
|
||||
uint16_t packet_id = next_packet_id_++;
|
||||
|
||||
for (size_t i = 0; i < num_frags; i++) {
|
||||
size_t offset = i * data_per_frag;
|
||||
size_t chunk_size = std::min(data_per_frag, data.size() - offset);
|
||||
|
||||
std::vector<uint8_t> frag;
|
||||
frag.reserve(Frag::HEADER_SIZE + chunk_size);
|
||||
|
||||
frag.push_back(Frag::MAGIC);
|
||||
frag.push_back((packet_id >> 8) & 0xFF);
|
||||
frag.push_back(packet_id & 0xFF);
|
||||
frag.push_back(static_cast<uint8_t>(i));
|
||||
|
||||
uint8_t flags = 0;
|
||||
if (i == 0) flags |= Frag::FLAG_FIRST_FRAGMENT;
|
||||
if (i < num_frags - 1) flags |= Frag::FLAG_MORE_FRAGMENTS;
|
||||
frag.push_back(flags);
|
||||
|
||||
frag.insert(frag.end(), data.begin() + offset, data.begin() + offset + chunk_size);
|
||||
fragments.push_back(std::move(frag));
|
||||
}
|
||||
|
||||
return fragments;
|
||||
}
|
||||
|
||||
bool needs_fragmentation(size_t data_size, size_t max_payload) const {
|
||||
return data_size > (max_payload - Frag::HEADER_SIZE);
|
||||
}
|
||||
|
||||
private:
|
||||
std::atomic<uint16_t> next_packet_id_;
|
||||
};
|
||||
|
||||
class Reassembler {
|
||||
public:
|
||||
Reassembler() = default;
|
||||
|
||||
std::vector<uint8_t> process(const std::vector<uint8_t>& fragment) {
|
||||
if (fragment.size() < Frag::HEADER_SIZE) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (fragment[0] != Frag::MAGIC) {
|
||||
return {};
|
||||
}
|
||||
|
||||
uint16_t packet_id = (fragment[1] << 8) | fragment[2];
|
||||
uint8_t seq = fragment[3];
|
||||
uint8_t flags = fragment[4];
|
||||
|
||||
std::vector<uint8_t> payload(fragment.begin() + Frag::HEADER_SIZE, fragment.end());
|
||||
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
|
||||
cleanup_stale();
|
||||
|
||||
auto& pkt = pending_[packet_id];
|
||||
if (pkt.fragments.empty()) {
|
||||
pkt.first_seen = std::chrono::steady_clock::now();
|
||||
}
|
||||
|
||||
pkt.fragments[seq] = std::move(payload);
|
||||
|
||||
if (flags & Frag::FLAG_FIRST_FRAGMENT) {
|
||||
pkt.has_first = true;
|
||||
}
|
||||
|
||||
if (!(flags & Frag::FLAG_MORE_FRAGMENTS)) {
|
||||
pkt.last_seq = seq;
|
||||
pkt.has_last = true;
|
||||
}
|
||||
|
||||
if (pkt.has_first && pkt.has_last) {
|
||||
bool complete = true;
|
||||
for (uint8_t i = 0; i <= pkt.last_seq; i++) {
|
||||
if (pkt.fragments.find(i) == pkt.fragments.end()) {
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (complete) {
|
||||
std::vector<uint8_t> reassembled;
|
||||
for (uint8_t i = 0; i <= pkt.last_seq; i++) {
|
||||
auto& frag_data = pkt.fragments[i];
|
||||
reassembled.insert(reassembled.end(), frag_data.begin(), frag_data.end());
|
||||
}
|
||||
pending_.erase(packet_id);
|
||||
return reassembled;
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
bool is_fragment(const std::vector<uint8_t>& data) const {
|
||||
if (data.size() < Frag::HEADER_SIZE) return false;
|
||||
return data[0] == Frag::MAGIC;
|
||||
}
|
||||
|
||||
void reset() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
pending_.clear();
|
||||
}
|
||||
|
||||
private:
|
||||
struct PendingPacket {
|
||||
std::map<uint8_t, std::vector<uint8_t>> fragments;
|
||||
std::chrono::steady_clock::time_point first_seen;
|
||||
uint8_t last_seq = 0;
|
||||
bool has_first = false;
|
||||
bool has_last = false;
|
||||
};
|
||||
|
||||
void cleanup_stale() {
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
for (auto it = pending_.begin(); it != pending_.end();) {
|
||||
auto age = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
now - it->second.first_seen).count();
|
||||
if (age > Frag::REASSEMBLY_TIMEOUT_MS) {
|
||||
it = pending_.erase(it);
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
|
||||
while (pending_.size() > Frag::MAX_PENDING_PACKETS) {
|
||||
auto oldest = pending_.begin();
|
||||
for (auto it = pending_.begin(); it != pending_.end(); ++it) {
|
||||
if (it->second.first_seen < oldest->second.first_seen) {
|
||||
oldest = it;
|
||||
}
|
||||
}
|
||||
pending_.erase(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
std::map<uint16_t, PendingPacket> pending_;
|
||||
mutable std::mutex mutex_;
|
||||
};
|
||||
|
|
|
|||
96
tnc_ui.hh
96
tnc_ui.hh
|
|
@ -91,6 +91,8 @@ struct TNCUIState {
|
|||
int mtu_bytes = 0;
|
||||
int bitrate_bps = 0;
|
||||
float airtime_seconds = 0.0f;
|
||||
int random_data_size = 0;
|
||||
bool fragmentation_enabled = false;
|
||||
|
||||
// stats
|
||||
std::atomic<float> total_tx_time{0.0f};
|
||||
|
|
@ -269,6 +271,13 @@ struct TNCUIState {
|
|||
bitrate_bps = bitrate_normal[mod][rate];
|
||||
airtime_seconds = duration_normal[mod] / 1000.0f;
|
||||
}
|
||||
|
||||
// Initialize random_data_size if not set, clamp to MTU only if fragmentation disabled
|
||||
if (random_data_size == 0) {
|
||||
random_data_size = mtu_bytes;
|
||||
} else if (!fragmentation_enabled && random_data_size > mtu_bytes) {
|
||||
random_data_size = mtu_bytes;
|
||||
}
|
||||
}
|
||||
|
||||
void update_level(float db) {
|
||||
|
|
@ -345,6 +354,7 @@ struct TNCUIState {
|
|||
fprintf(f, "carrier_threshold_db=%.1f\n", carrier_threshold_db);
|
||||
fprintf(f, "slot_time_ms=%d\n", slot_time_ms);
|
||||
fprintf(f, "p_persistence=%d\n", p_persistence);
|
||||
fprintf(f, "fragmentation_enabled=%d\n", fragmentation_enabled ? 1 : 0);
|
||||
fprintf(f, "# Audio/PTT\n");
|
||||
fprintf(f, "audio_input=%s\n", audio_input_device.c_str());
|
||||
fprintf(f, "audio_output=%s\n", audio_output_device.c_str());
|
||||
|
|
@ -359,6 +369,8 @@ struct TNCUIState {
|
|||
fprintf(f, "com_invert_rts=%d\n", com_invert_rts ? 1 : 0);
|
||||
fprintf(f, "# Network\n");
|
||||
fprintf(f, "port=%d\n", port);
|
||||
fprintf(f, "# Utils\n");
|
||||
fprintf(f, "random_data_size=%d\n", random_data_size);
|
||||
|
||||
fclose(f);
|
||||
return true;
|
||||
|
|
@ -386,6 +398,7 @@ struct TNCUIState {
|
|||
else if (strcmp(key, "carrier_threshold_db") == 0) carrier_threshold_db = atof(value);
|
||||
else if (strcmp(key, "slot_time_ms") == 0) slot_time_ms = atoi(value);
|
||||
else if (strcmp(key, "p_persistence") == 0) p_persistence = atoi(value);
|
||||
else if (strcmp(key, "fragmentation_enabled") == 0) fragmentation_enabled = atoi(value) != 0;
|
||||
else if (strcmp(key, "audio_input") == 0) audio_input_device = value;
|
||||
else if (strcmp(key, "audio_output") == 0) audio_output_device = value;
|
||||
else if (strcmp(key, "audio_device") == 0) {
|
||||
|
|
@ -401,6 +414,7 @@ struct TNCUIState {
|
|||
else if (strcmp(key, "com_invert_dtr") == 0) com_invert_dtr = atoi(value) != 0;
|
||||
else if (strcmp(key, "com_invert_rts") == 0) com_invert_rts = atoi(value) != 0;
|
||||
else if (strcmp(key, "port") == 0) port = atoi(value);
|
||||
else if (strcmp(key, "random_data_size") == 0) random_data_size = atoi(value);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -657,7 +671,8 @@ private:
|
|||
FIELD_FREQ,
|
||||
FIELD_CSMA,
|
||||
FIELD_THRESHOLD,
|
||||
FIELD_PERSISTENCE,
|
||||
FIELD_PERSISTENCE,
|
||||
FIELD_FRAGMENTATION,
|
||||
FIELD_AUDIO_INPUT,
|
||||
FIELD_AUDIO_OUTPUT,
|
||||
FIELD_PTT_TYPE,
|
||||
|
|
@ -747,6 +762,11 @@ private:
|
|||
} else if (current_field_ >= FIELD_MODULATION && current_field_ != FIELD_PRESET) {
|
||||
adjust_field(-1);
|
||||
}
|
||||
} else if (current_tab_ == 3 && (utils_selection_ == 0 || utils_selection_ == 1)) {
|
||||
int step = 1;
|
||||
if (state_.random_data_size >= 1000) step = 100;
|
||||
else if (state_.random_data_size >= 100) step = 10;
|
||||
state_.random_data_size = std::max(1, state_.random_data_size - step);
|
||||
}
|
||||
break;
|
||||
|
||||
|
|
@ -763,6 +783,12 @@ private:
|
|||
} else if (current_field_ >= FIELD_MODULATION && current_field_ != FIELD_PRESET) {
|
||||
adjust_field(1);
|
||||
}
|
||||
} else if (current_tab_ == 3 && (utils_selection_ == 0 || utils_selection_ == 1)) {
|
||||
int step = 1;
|
||||
if (state_.random_data_size >= 1000) step = 100;
|
||||
else if (state_.random_data_size >= 100) step = 10;
|
||||
int max_size = state_.fragmentation_enabled ? 65535 : state_.mtu_bytes;
|
||||
state_.random_data_size = std::min(max_size, state_.random_data_size + step);
|
||||
}
|
||||
break;
|
||||
|
||||
|
|
@ -1112,6 +1138,11 @@ private:
|
|||
state_.p_persistence += delta * 8;
|
||||
state_.p_persistence = std::max(0, std::min(255, state_.p_persistence));
|
||||
break;
|
||||
case FIELD_FRAGMENTATION:
|
||||
state_.fragmentation_enabled = !state_.fragmentation_enabled;
|
||||
state_.update_modem_info(); // Update random_data_size limits
|
||||
state_.add_log("(!) Fragmentation changed, restart required");
|
||||
break;
|
||||
case FIELD_AUDIO_INPUT:
|
||||
break;
|
||||
case FIELD_AUDIO_OUTPUT:
|
||||
|
|
@ -2115,6 +2146,20 @@ private:
|
|||
}
|
||||
row += 2;
|
||||
|
||||
// Fragmentation section
|
||||
dy = visible_y(row);
|
||||
if (dy >= 0) {
|
||||
attron(A_DIM);
|
||||
mvaddstr(dy, c1, "FRAGMENTATION");
|
||||
mvaddstr(dy, c1 + 14, "(restart)");
|
||||
attroff(A_DIM);
|
||||
}
|
||||
row++;
|
||||
|
||||
dy = visible_y(row);
|
||||
if (dy >= 0) draw_toggle_field(dy, c1, c2, "Enabled", FIELD_FRAGMENTATION, state_.fragmentation_enabled);
|
||||
row += 2;
|
||||
|
||||
// Audio / ptt
|
||||
|
||||
dy = visible_y(row);
|
||||
|
|
@ -2625,28 +2670,56 @@ private:
|
|||
|
||||
y++;
|
||||
|
||||
// Test info
|
||||
attron(COLOR_PAIR(4) | A_BOLD);
|
||||
mvaddstr(y, c1, "[ TEST INFO ]");
|
||||
attroff(COLOR_PAIR(4) | A_BOLD);
|
||||
y++;
|
||||
|
||||
attron(A_DIM);
|
||||
mvaddstr(y, c1, "Payload");
|
||||
mvaddstr(y, c1, "MTU");
|
||||
attroff(A_DIM);
|
||||
mvprintw(y, c1 + 12, "%d bytes", state_.mtu_bytes);
|
||||
mvprintw(y, c1 + 14, "%d bytes", state_.mtu_bytes);
|
||||
if (state_.fragmentation_enabled) {
|
||||
attron(COLOR_PAIR(4));
|
||||
printw(" [FRAG]");
|
||||
attroff(COLOR_PAIR(4));
|
||||
}
|
||||
y++;
|
||||
|
||||
bool size_selected = (utils_selection_ == 0 || utils_selection_ == 1);
|
||||
if (size_selected) {
|
||||
attron(A_BOLD | COLOR_PAIR(4));
|
||||
} else {
|
||||
attron(A_DIM);
|
||||
}
|
||||
mvaddstr(y, c1, "Test Size");
|
||||
if (size_selected) {
|
||||
attroff(A_BOLD | COLOR_PAIR(4));
|
||||
mvprintw(y, c1 + 14, "< %d bytes >", state_.random_data_size);
|
||||
} else {
|
||||
attroff(A_DIM);
|
||||
mvprintw(y, c1 + 14, "%d bytes", state_.random_data_size);
|
||||
}
|
||||
|
||||
if (state_.fragmentation_enabled && state_.random_data_size > state_.mtu_bytes) {
|
||||
int data_per_frag = state_.mtu_bytes - 5; // 5-byte fragment header
|
||||
int num_frags = (state_.random_data_size + data_per_frag - 1) / data_per_frag;
|
||||
attron(COLOR_PAIR(3));
|
||||
printw(" (%d frags)", num_frags);
|
||||
attroff(COLOR_PAIR(3));
|
||||
}
|
||||
y++;
|
||||
|
||||
attron(A_DIM);
|
||||
mvaddstr(y, c1, "Pattern");
|
||||
attroff(A_DIM);
|
||||
mvaddstr(y, c1 + 12, "0x55 (alternating)");
|
||||
mvaddstr(y, c1 + 14, "0x55 (alternating)");
|
||||
y++;
|
||||
|
||||
attron(A_DIM);
|
||||
mvaddstr(y, c1, "Frames Sent");
|
||||
attroff(A_DIM);
|
||||
mvprintw(y, c1 + 12, "%d", state_.tx_frame_count.load());
|
||||
mvprintw(y, c1 + 14, "%d", state_.tx_frame_count.load());
|
||||
y++;
|
||||
|
||||
int ry = 4;
|
||||
|
|
@ -2704,29 +2777,26 @@ private:
|
|||
void handle_utils_action() {
|
||||
switch (utils_selection_) {
|
||||
case 0: {
|
||||
// Send test pattern
|
||||
if (state_.on_send_data) {
|
||||
std::vector<uint8_t> data(state_.mtu_bytes, 0x55);
|
||||
std::vector<uint8_t> data(state_.random_data_size, 0x55);
|
||||
state_.on_send_data(data);
|
||||
state_.add_log("Sent test pattern (" + std::to_string(state_.mtu_bytes) + " bytes)");
|
||||
state_.add_log("Sent test pattern (" + std::to_string(state_.random_data_size) + " bytes)");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 1: {
|
||||
// Send random data
|
||||
if (state_.on_send_data) {
|
||||
std::vector<uint8_t> data(state_.mtu_bytes);
|
||||
std::vector<uint8_t> data(state_.random_data_size);
|
||||
std::random_device rd;
|
||||
std::mt19937 gen(rd());
|
||||
std::uniform_int_distribution<> dis(0, 255);
|
||||
for (auto& b : data) b = dis(gen);
|
||||
state_.on_send_data(data);
|
||||
state_.add_log("Sent random data (" + std::to_string(state_.mtu_bytes) + " bytes)");
|
||||
state_.add_log("Sent random data (" + std::to_string(state_.random_data_size) + " bytes)");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 2: {
|
||||
// Send ping
|
||||
if (state_.on_send_data) {
|
||||
std::string ping = "PING:" + state_.callsign;
|
||||
std::vector<uint8_t> data(ping.begin(), ping.end());
|
||||
|
|
|
|||
112
update.sh
Executable file
112
update.sh
Executable file
|
|
@ -0,0 +1,112 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# modem73 update script
|
||||
# https://github.com/RFnexus/modem73
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
REPO_URL="https://github.com/RFnexus/modem73.git"
|
||||
INSTALL_DIR="${MODEM73_DIR:-$HOME/modem73}"
|
||||
|
||||
if [ -t 1 ]; then
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
else
|
||||
RED=''
|
||||
GREEN=''
|
||||
YELLOW=''
|
||||
NC=''
|
||||
fi
|
||||
|
||||
info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
|
||||
|
||||
command -v git >/dev/null 2>&1 || error "git is required but not installed"
|
||||
command -v make >/dev/null 2>&1 || error "make is required but not installed"
|
||||
|
||||
|
||||
if command -v g++ >/dev/null 2>&1; then
|
||||
CXX="g++"
|
||||
elif command -v clang++ >/dev/null 2>&1; then
|
||||
CXX="clang++"
|
||||
else
|
||||
error "C++ compiler (g++ or clang++) is required but not installed"
|
||||
fi
|
||||
|
||||
info "modem73 updater"
|
||||
echo " Repository: $REPO_URL"
|
||||
echo " Install to: $INSTALL_DIR"
|
||||
echo ""
|
||||
|
||||
if [ -d "$INSTALL_DIR/.git" ]; then
|
||||
info "Updating = installation..."
|
||||
cd "$INSTALL_DIR"
|
||||
|
||||
if ! git diff --quiet 2>/dev/null; then
|
||||
warn "Local changes detected. Stashing..."
|
||||
git stash
|
||||
fi
|
||||
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
|
||||
git fetch origin
|
||||
LOCAL=$(git rev-parse HEAD)
|
||||
REMOTE=$(git rev-parse "origin/$BRANCH")
|
||||
|
||||
if [ "$LOCAL" = "$REMOTE" ]; then
|
||||
info "Already up to date"
|
||||
NEEDS_BUILD=0
|
||||
else
|
||||
info "Pulling updates..."
|
||||
git pull --ff-only origin "$BRANCH"
|
||||
NEEDS_BUILD=1
|
||||
fi
|
||||
else
|
||||
info "Cloning repository..."
|
||||
mkdir -p "$(dirname "$INSTALL_DIR")"
|
||||
git clone "$REPO_URL" "$INSTALL_DIR"
|
||||
cd "$INSTALL_DIR"
|
||||
NEEDS_BUILD=1
|
||||
fi
|
||||
|
||||
# Build
|
||||
if [ "${NEEDS_BUILD:-1}" = "1" ] || [ "$1" = "--force" ]; then
|
||||
info "Building modem73..."
|
||||
|
||||
|
||||
|
||||
make clean 2>/dev/null || true
|
||||
|
||||
|
||||
|
||||
if make -j"$(nproc 2>/dev/null || echo 2)"; then
|
||||
info "Build sucessful!"
|
||||
|
||||
|
||||
|
||||
|
||||
echo ""
|
||||
echo " Commit: $(git rev-parse --short HEAD)"
|
||||
echo " Date: $(git log -1 --format=%ci)"
|
||||
echo ""
|
||||
|
||||
|
||||
|
||||
|
||||
if [ -f "$INSTALL_DIR/modem73" ]; then
|
||||
info "Binary: $INSTALL_DIR/modem73"
|
||||
fi
|
||||
else
|
||||
error "Build failed"
|
||||
fi
|
||||
else
|
||||
info "No build needed (use --force to rebuild)"
|
||||
fi
|
||||
|
||||
|
||||
|
||||
info "Done!"
|
||||
Loading…
Add table
Add a link
Reference in a new issue