Compare commits

...

28 Commits

Author SHA1 Message Date
240d5cbd4b add slave response frame 2025-09-19 18:02:37 +02:00
3384c89475 add UDP command processing and sender for dynamic testing 2025-09-15 11:17:33 +02:00
8591e7433d truma_inetbox: refine master mode discovery with correct CP Plus signatures 2025-09-14 23:31:04 +02:00
4c097a576d truma_inetbox: enhance device discovery with targeted queries and logging 2025-09-14 23:00:01 +02:00
a0837070e2 add TriggerDiscoveryButton component with discovery functionality 2025-09-14 22:40:37 +02:00
958293f686 add CP Plus identifier for master mode in lin_identifier function 2025-09-14 22:05:27 +02:00
f022db25c4 truma_inetbox: enhance master mode device discovery with targeted queries 2025-09-14 21:22:22 +02:00
98ad6caab9 truma_inetbox: implement master mode auto-discovery and device scanning
udp_lin_receiver: add option to suppress repeated identical messages
2025-09-14 21:07:18 +02:00
aa5a85ec42 remove unused COMPONENT_VERSION constant 2025-09-14 16:55:28 +02:00
29235365dc rm test 2025-09-09 21:09:48 +02:00
ac8d902c08 print component version 2025-09-09 17:07:03 +02:00
451c184dda truma_inetbox(stream): stream slave responses too (enqueue in ISR, format/send in background); tag MASTER/SLAVE 2025-09-09 11:57:14 +02:00
7248f611df truma_inetbox(stream): decouple UDP streaming from logger level by streaming master frames from lin_msg queue 2025-09-09 11:54:25 +02:00
02cafc62dd truma_inetbox(stream): pin sender task to core 1; fix %s typo; keep keepalive/test queued; remove WiFi.h dependency 2025-09-09 11:29:15 +02:00
b5f02d0a47 truma_inetbox(stream): enqueue keepalive/test; add SO_SNDTIMEO on UDP socket 2025-09-09 10:53:37 +02:00
d0632b8457 truma_inetbox: fix ESP32 streaming preprocessor guards 2025-09-09 02:14:17 +02:00
382a4d3b4a truma_inetbox(stream): move UDP sending to background task
- Add FreeRTOS queue + dedicated sender task to avoid blocking main loop
- Short socket send timeout to prevent stalls
- Keepalive and PID lines enqueued; drop on full queue
- Add stream_send_test() to verify UDP path
2025-09-09 01:39:59 +02:00
99249dfaa9 truma_inetbox: add stream_send_test() helper to trigger a UDP test line 2025-09-08 20:02:05 +02:00
9e07fb6953 truma_inetbox: add UDP streaming (ESP32)
- observer_mode sniffer UDP: create socket, send 'PID XX <hex>' lines
- Keepalive with stream_keepalive_ms
- stream_diag_only filters to 0x3C/0x3D
- Controlled by stream_enabled/host/port
2025-09-08 17:11:53 +02:00
4719d483a7 truma_inetbox: expose observer_mode in CONFIG_SCHEMA and wire to set_observer_mode() 2025-09-08 16:30:34 +02:00
c81ffdcb12 tools: add udp_lin_receiver.py for capturing and diffing UDP LIN stream
- Records each datagram with timestamp to CSV
- Prints live console summary
- Optional --pid-diff mode parses textual lines like 'PID XX <hex>' and only prints on data changes
2025-09-08 16:20:46 +02:00
4298e675a5 truma_inetbox: fix compile on ESP32 Arduino
- Add missing member fields for stream+master config in LinBusListener
- Expose write_lin_master_frame_ as protected
- Make LinBusLog use <cstdint> and uint8_t
- Add <queue> + declarations for lin_reset_device/has_update_to_submit in TrumaiNetBoxApp.h
- Arduino: fix UART calls and add helper prototypes in LinBusListener_esp32_arduino.cpp

Tested: esphome compile tests/test.esp32_ard.yaml (build OK).
2025-09-08 14:53:35 +02:00
Automation
891d3c59e8 fix(c++): remove stray CRLF escapes and place write_lin_master_frame_ inside namespaces 2025-09-08 12:23:59 +02:00
8db35ea7a5 fix: correct formatting of import statements in __init__.py 2025-09-08 11:06:33 +02:00
Automation
e717bf7d85 fix(py): import CONF_TIME for clock action requires_component 2025-09-08 11:02:15 +02:00
Automation
de21822c7a fix(py): correct indentation in to_code for stream options 2025-09-08 10:54:13 +02:00
Automation
d5942af8a0 fix: correct __init__.py imports and add stream+master options to schema/to_code 2025-09-08 10:32:19 +02:00
Automation
422fc3469e feat(master): add guarded LIN master TX (ESP32 Arduino), B2 scanner API, and scheduler 2025-09-07 23:09:51 +02:00
12 changed files with 884 additions and 25 deletions

View File

@ -2,6 +2,11 @@
#include "esphome/core/helpers.h" #include "esphome/core/helpers.h"
#include "esphome/core/log.h" #include "esphome/core/log.h"
#include "helpers.h" #include "helpers.h"
#ifdef USE_ESP32
#include <lwip/sockets.h>
#include <lwip/inet.h>
#include <sys/time.h>
#endif
namespace esphome { namespace esphome {
namespace truma_inetbox { namespace truma_inetbox {
@ -57,6 +62,23 @@ void LinBusListener::setup() {
// call device specific function // call device specific function
this->setup_framework(); this->setup_framework();
#ifdef USE_ESP32
if (this->stream_queue_ == nullptr) {
this->stream_queue_ = xQueueCreateStatic(
STREAM_QUEUE_LEN, STREAM_QUEUE_ITEM_MAX,
stream_static_queue_storage_, &stream_static_queue_);
}
if (this->streamTaskHandle_ == nullptr) {
xTaskCreatePinnedToCore(LinBusListener::streamTask_,
"lin_stream_task",
4096,
this,
1,
&this->streamTaskHandle_,
1); // pin to core 1 to avoid impacting WiFi on core 0
}
#endif
#if ESPHOME_LOG_LEVEL > ESPHOME_LOG_LEVEL_NONE #if ESPHOME_LOG_LEVEL > ESPHOME_LOG_LEVEL_NONE
assert(this->log_queue_ != 0); assert(this->log_queue_ != 0);
@ -70,7 +92,15 @@ void LinBusListener::setup() {
} }
} }
void LinBusListener::update() { this->check_for_lin_fault_(); } void LinBusListener::update() {
this->check_for_lin_fault_();
#ifdef USE_ESP32
if (this->stream_enabled_ && this->udp_sock_ < 0) {
this->stream_try_init_();
}
this->stream_maybe_keepalive_();
#endif
}
void LinBusListener::write_lin_answer_(const u_int8_t *data, u_int8_t len) { void LinBusListener::write_lin_answer_(const u_int8_t *data, u_int8_t len) {
QUEUE_LOG_MSG log_msg = QUEUE_LOG_MSG(); QUEUE_LOG_MSG log_msg = QUEUE_LOG_MSG();
@ -306,13 +336,14 @@ void LinBusListener::read_lin_frame_() {
TRUMA_LOGV_ISR(log_msg); TRUMA_LOGV_ISR(log_msg);
#endif // ESPHOME_LOG_HAS_VERBOSE #endif // ESPHOME_LOG_HAS_VERBOSE
if (this->current_data_valid && message_from_master) { if (this->current_data_valid) {
QUEUE_LIN_MSG lin_msg; QUEUE_LIN_MSG lin_msg;
lin_msg.current_PID = this->current_PID_; lin_msg.current_PID = this->current_PID_;
lin_msg.len = this->current_data_count_ - 1; lin_msg.len = this->current_data_count_ - 1; // exclude CRC
for (u_int8_t i = 0; i < lin_msg.len; i++) { for (u_int8_t i = 0; i < lin_msg.len; i++) {
lin_msg.data[i] = this->current_data_[i]; lin_msg.data[i] = this->current_data_[i];
} }
lin_msg.from_master = message_from_master ? 1 : 0;
xQueueSendFromISR(this->lin_msg_queue_, (void *) &lin_msg, QUEUE_WAIT_DONT_BLOCK); xQueueSendFromISR(this->lin_msg_queue_, (void *) &lin_msg, QUEUE_WAIT_DONT_BLOCK);
} }
this->current_state_ = READ_STATE_BREAK; this->current_state_ = READ_STATE_BREAK;
@ -328,6 +359,10 @@ void LinBusListener::clear_uart_buffer_() {
void LinBusListener::process_lin_msg_queue(TickType_t xTicksToWait) { void LinBusListener::process_lin_msg_queue(TickType_t xTicksToWait) {
QUEUE_LIN_MSG lin_msg; QUEUE_LIN_MSG lin_msg;
while (xQueueReceive(this->lin_msg_queue_, &lin_msg, xTicksToWait) == pdPASS) { while (xQueueReceive(this->lin_msg_queue_, &lin_msg, xTicksToWait) == pdPASS) {
#ifdef USE_ESP32
// Also forward master frames to UDP stream regardless of logger level
this->maybe_send_stream_from_lin_msg_(lin_msg);
#endif
this->lin_message_recieved_(lin_msg.current_PID, lin_msg.data, lin_msg.len); this->lin_message_recieved_(lin_msg.current_PID, lin_msg.data, lin_msg.len);
} }
} }
@ -337,6 +372,8 @@ void LinBusListener::process_log_queue(TickType_t xTicksToWait) {
QUEUE_LOG_MSG log_msg; QUEUE_LOG_MSG log_msg;
while (xQueueReceive(this->log_queue_, &log_msg, xTicksToWait) == pdPASS) { while (xQueueReceive(this->log_queue_, &log_msg, xTicksToWait) == pdPASS) {
auto current_PID = log_msg.current_PID; auto current_PID = log_msg.current_PID;
// Optional: forward to UDP stream before/after printing
this->maybe_send_stream_(log_msg);
switch (log_msg.type) { switch (log_msg.type) {
case QUEUE_LOG_MSG_TYPE::ERROR_LIN_ANSWER_CAN_WRITE_LIN_ANSWER: case QUEUE_LOG_MSG_TYPE::ERROR_LIN_ANSWER_CAN_WRITE_LIN_ANSWER:
ESP_LOGE(TAG, "Cannot answer LIN because there is no open order from master."); ESP_LOGE(TAG, "Cannot answer LIN because there is no open order from master.");
@ -393,7 +430,7 @@ void LinBusListener::process_log_queue(TickType_t xTicksToWait) {
log_msg.message_source_know ? (log_msg.message_from_master ? " - MASTER" : " - SLAVE") : "", log_msg.message_source_know ? (log_msg.message_from_master ? " - MASTER" : " - SLAVE") : "",
log_msg.current_data_valid ? "" : "INVALID"); log_msg.current_data_valid ? "" : "INVALID");
} else { } else {
ESP_LOGV(TAG, "PID %02X %s %s %S", current_PID_, format_hex_pretty(log_msg.data, log_msg.len).c_str(), ESP_LOGV(TAG, "PID %02X %s %s %s", current_PID_, format_hex_pretty(log_msg.data, log_msg.len).c_str(),
log_msg.message_source_know ? (log_msg.message_from_master ? " - MASTER" : " - SLAVE") : "", log_msg.message_source_know ? (log_msg.message_from_master ? " - MASTER" : " - SLAVE") : "",
log_msg.current_data_valid ? "" : "INVALID"); log_msg.current_data_valid ? "" : "INVALID");
} }
@ -405,6 +442,155 @@ void LinBusListener::process_log_queue(TickType_t xTicksToWait) {
#endif // ESPHOME_LOG_LEVEL > ESPHOME_LOG_LEVEL_NONE #endif // ESPHOME_LOG_LEVEL > ESPHOME_LOG_LEVEL_NONE
} }
#ifdef USE_ESP32
void LinBusListener::maybe_send_stream_from_lin_msg_(const QUEUE_LIN_MSG &lin_msg) {
if (!this->stream_enabled_) return;
if (this->udp_sock_ < 0) this->stream_try_init_();
if (this->udp_sock_ < 0) return;
const uint8_t pid = lin_msg.current_PID;
if (this->stream_diag_only_ && !(pid == DIAGNOSTIC_FRAME_MASTER || pid == DIAGNOSTIC_FRAME_SLAVE)) {
return;
}
std::string line;
line.reserve(64);
char head[16];
snprintf(head, sizeof(head), "PID %02X ", pid);
line += head;
// Local hex format to avoid dependency on verbose-only helper
{
char b[4];
for (uint8_t i = 0; i < lin_msg.len; i++) {
if (!line.empty() && line.back() != ' ') line.push_back(' ');
snprintf(b, sizeof(b), "%02X", lin_msg.data[i]);
line += b;
}
}
line += (lin_msg.from_master ? " MASTER" : " SLAVE");
line.push_back('\n');
this->stream_enqueue_line_(line);
}
void LinBusListener::stream_send_test(const std::string &line) {
if (!this->stream_enabled_) return;
if (this->udp_sock_ < 0) this->stream_try_init_();
// Always enqueue to avoid blocking the main loop
std::string msg = line;
if (msg.empty()) msg = "STREAM TEST";
msg.push_back('\n');
this->stream_enqueue_line_(msg);
}
void LinBusListener::stream_try_init_() {
if (!this->stream_enabled_) return;
if (this->udp_sock_ >= 0) return;
if (this->udp_host_.empty() || this->udp_port_ == 0) {
ESP_LOGI(TAG, "UDP stream disabled: missing host/port");
return;
}
this->udp_sock_ = ::socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (this->udp_sock_ < 0) {
ESP_LOGW(TAG, "UDP socket create failed");
return;
}
// Set a small send timeout so UDP sendto doesn't block long on ARP
struct timeval tv;
tv.tv_sec = 0;
tv.tv_usec = 50000; // 50ms
setsockopt(this->udp_sock_, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
memset(&this->udp_addr_, 0, sizeof(this->udp_addr_));
this->udp_addr_.sin_family = AF_INET;
this->udp_addr_.sin_port = htons(this->udp_port_);
this->udp_addr_.sin_addr.s_addr = inet_addr(this->udp_host_.c_str());
if (this->udp_addr_.sin_addr.s_addr == (in_addr_t) -1) {
ESP_LOGW(TAG, "UDP host invalid: %s", this->udp_host_.c_str());
::close(this->udp_sock_);
this->udp_sock_ = -1;
return;
}
ESP_LOGI(TAG, "UDP streaming to %s:%u", this->udp_host_.c_str(), (unsigned) this->udp_port_);
this->last_stream_send_ms_ = millis();
}
static std::string hex_bytes_(const uint8_t *data, uint8_t len) {
char buf[4];
std::string out;
out.reserve(len * 3);
for (uint8_t i = 0; i < len; i++) {
snprintf(buf, sizeof(buf), "%02X", data[i]);
if (!out.empty()) out.push_back(' ');
out += buf;
}
return out;
}
void LinBusListener::stream_maybe_keepalive_() {
if (!this->stream_enabled_) return;
const uint32_t now = millis();
if (this->stream_keepalive_ms_ == 0) return;
if (now - this->last_stream_send_ms_ >= this->stream_keepalive_ms_) {
// Enqueue keepalive to be sent from the background sender task
this->stream_enqueue_line_(std::string("KEEPALIVE\n"));
this->last_stream_send_ms_ = now;
}
}
void LinBusListener::maybe_send_stream_(const QUEUE_LOG_MSG &log_msg) {
if (!this->stream_enabled_) return;
if (this->udp_sock_ < 0) this->stream_try_init_();
if (this->udp_sock_ < 0) return;
const uint8_t pid = log_msg.current_PID;
if (this->stream_diag_only_ && !(pid == DIAGNOSTIC_FRAME_MASTER || pid == DIAGNOSTIC_FRAME_SLAVE)) {
return;
}
if (log_msg.len == 0) return;
// Compose a compact line: "PID XX <hex> [MASTER|SLAVE]"
std::string line;
line.reserve(64);
char head[16];
snprintf(head, sizeof(head), "PID %02X ", pid);
line += head;
line += hex_bytes_(log_msg.data, log_msg.len);
#ifdef ESPHOME_LOG_HAS_VERBOSE
if (log_msg.message_source_know) {
line += (log_msg.message_from_master ? " MASTER" : " SLAVE");
}
#endif
line.push_back('\n');
this->stream_enqueue_line_(line);
}
void LinBusListener::stream_enqueue_line_(const std::string &line) {
if (!this->stream_queue_) return;
if (line.empty()) return;
char buf[STREAM_QUEUE_ITEM_MAX];
size_t n = line.size();
if (n >= sizeof(buf)) n = sizeof(buf) - 1;
memcpy(buf, line.data(), n);
buf[n] = '\0';
xQueueSend(this->stream_queue_, buf, 0);
}
void LinBusListener::streamTask_(void *args) {
auto *self = static_cast<LinBusListener*>(args);
char buf[STREAM_QUEUE_ITEM_MAX];
for (;;) {
if (xQueueReceive(self->stream_queue_, buf, pdMS_TO_TICKS(100)) == pdPASS) {
if (self->udp_sock_ >= 0) {
size_t len = strnlen(buf, sizeof(buf));
::sendto(self->udp_sock_, buf, len, 0, (struct sockaddr *) &self->udp_addr_, sizeof(self->udp_addr_));
self->last_stream_send_ms_ = millis();
}
}
}
}
#else
void LinBusListener::maybe_send_stream_(const QUEUE_LOG_MSG &) {}
void LinBusListener::stream_try_init_() {}
void LinBusListener::stream_maybe_keepalive_() {}
void LinBusListener::stream_enqueue_line_(const std::string &) {}
void LinBusListener::streamTask_(void *) {}
#endif
#undef LIN_BREAK #undef LIN_BREAK
#undef LIN_SYNC #undef LIN_SYNC
#undef DIAGNOSTIC_FRAME_MASTER #undef DIAGNOSTIC_FRAME_MASTER

View File

@ -7,6 +7,8 @@
#ifdef USE_ESP32 #ifdef USE_ESP32
#include <freertos/FreeRTOS.h> #include <freertos/FreeRTOS.h>
#include <freertos/semphr.h> #include <freertos/semphr.h>
#include <lwip/sockets.h>
#include <lwip/inet.h>
#endif // USE_ESP32 #endif // USE_ESP32
#ifdef USE_RP2040 #ifdef USE_RP2040
#include <hardware/uart.h> #include <hardware/uart.h>
@ -31,6 +33,7 @@ struct QUEUE_LIN_MSG {
u_int8_t current_PID; u_int8_t current_PID;
u_int8_t data[8]; u_int8_t data[8];
u_int8_t len; u_int8_t len;
u_int8_t from_master; // 1 = master order, 0 = slave response
}; };
class LinBusListener : public PollingComponent, public uart::UARTDevice { class LinBusListener : public PollingComponent, public uart::UARTDevice {
@ -46,21 +49,51 @@ class LinBusListener : public PollingComponent, public uart::UARTDevice {
void set_fault_pin(GPIOPin *pin) { this->fault_pin_ = pin; } void set_fault_pin(GPIOPin *pin) { this->fault_pin_ = pin; }
void set_observer_mode(bool val) { this->observer_mode_ = val; } void set_observer_mode(bool val) { this->observer_mode_ = val; }
bool get_lin_bus_fault() { return fault_on_lin_bus_reported_ > 3; } bool get_lin_bus_fault() { return fault_on_lin_bus_reported_ > 3; }
// Send a manual UDP test line (when streaming is enabled)
void stream_send_test(const std::string &line);
void process_lin_msg_queue(TickType_t xTicksToWait); void process_lin_msg_queue(TickType_t xTicksToWait);
void process_log_queue(TickType_t xTicksToWait); void process_log_queue(TickType_t xTicksToWait);
// Streaming configuration (UDP)
void set_stream_enabled(bool val) { this->stream_enabled_ = val; }
void set_stream_diag_only(bool val) { this->stream_diag_only_ = val; }
void set_stream_keepalive_ms(uint32_t val) { this->stream_keepalive_ms_ = val; }
void set_udp_stream_host(const std::string &host) { this->udp_host_ = host; }
void set_udp_stream_port(uint16_t port) { this->udp_port_ = port; }
// Master (LIN initiator) configuration
void set_master_mode(bool val) { this->master_mode_ = val; }
void set_master_nad(uint8_t nad) { this->master_nad_ = nad; }
void set_writes_armed(bool val) { this->writes_armed_ = val; }
#ifdef USE_RP2040 #ifdef USE_RP2040
// Return is the expected wait time till next data check is recommended. // Return is the expected wait time till next data check is recommended.
u_int32_t onSerialEvent(); u_int32_t onSerialEvent();
#endif // USE_RP2040 #endif // USE_RP2040
protected: protected:
// Low-level: send a LIN master frame (break + sync + pid + data + crc)
bool write_lin_master_frame_(uint8_t pid, const uint8_t *data, uint8_t len);
LIN_CHECKSUM lin_checksum_ = LIN_CHECKSUM::LIN_CHECKSUM_VERSION_2; LIN_CHECKSUM lin_checksum_ = LIN_CHECKSUM::LIN_CHECKSUM_VERSION_2;
GPIOPin *cs_pin_ = nullptr; GPIOPin *cs_pin_ = nullptr;
GPIOPin *fault_pin_ = nullptr; GPIOPin *fault_pin_ = nullptr;
bool observer_mode_ = false; bool observer_mode_ = false;
// Streaming config (UDP)
bool stream_enabled_ = false;
bool stream_diag_only_ = false;
uint32_t stream_keepalive_ms_ = 1000;
std::string udp_host_ = {};
uint16_t udp_port_ = 0;
// Master (LIN initiator) config
bool master_mode_ = false;
uint8_t master_nad_ = 0x00;
bool writes_armed_ = false;
void write_lin_answer_(const u_int8_t *data, u_int8_t len); void write_lin_answer_(const u_int8_t *data, u_int8_t len);
bool check_for_lin_fault_(); bool check_for_lin_fault_();
virtual bool answer_lin_order_(const u_int8_t pid) = 0; virtual bool answer_lin_order_(const u_int8_t pid) = 0;
@ -115,6 +148,11 @@ class LinBusListener : public PollingComponent, public uart::UARTDevice {
void read_lin_frame_(); void read_lin_frame_();
void clear_uart_buffer_(); void clear_uart_buffer_();
void setup_framework(); void setup_framework();
void maybe_send_stream_(const QUEUE_LOG_MSG &log_msg);
void stream_try_init_();
void stream_maybe_keepalive_();
void stream_enqueue_line_(const std::string &line);
void maybe_send_stream_from_lin_msg_(const QUEUE_LIN_MSG &lin_msg);
uint8_t lin_msg_static_queue_storage[TRUMA_MSG_QUEUE_LENGTH * sizeof(QUEUE_LIN_MSG)]; uint8_t lin_msg_static_queue_storage[TRUMA_MSG_QUEUE_LENGTH * sizeof(QUEUE_LIN_MSG)];
StaticQueue_t lin_msg_static_queue_; StaticQueue_t lin_msg_static_queue_;
@ -135,6 +173,18 @@ class LinBusListener : public PollingComponent, public uart::UARTDevice {
#ifdef USE_ESP32 #ifdef USE_ESP32
TaskHandle_t eventTaskHandle_; TaskHandle_t eventTaskHandle_;
static void eventTask_(void *args); static void eventTask_(void *args);
// UDP streaming (ESP32 only)
int udp_sock_ = -1;
struct sockaddr_in udp_addr_ {};
uint32_t last_stream_send_ms_ = 0;
// Background sender task + queue to avoid blocking main loop
TaskHandle_t streamTaskHandle_ = nullptr;
static void streamTask_(void *args);
static constexpr size_t STREAM_QUEUE_ITEM_MAX = 120; // bytes per line
static constexpr size_t STREAM_QUEUE_LEN = 16;
uint8_t stream_static_queue_storage_[STREAM_QUEUE_LEN * STREAM_QUEUE_ITEM_MAX];
StaticQueue_t stream_static_queue_;
QueueHandle_t stream_queue_ = nullptr;
#endif // USE_ESP32 #endif // USE_ESP32
#ifdef USE_ESP32_FRAMEWORK_ESP_IDF #ifdef USE_ESP32_FRAMEWORK_ESP_IDF
TaskHandle_t uartEventTaskHandle_; TaskHandle_t uartEventTaskHandle_;
@ -147,4 +197,4 @@ class LinBusListener : public PollingComponent, public uart::UARTDevice {
}; };
} // namespace truma_inetbox } // namespace truma_inetbox
} // namespace esphome } // namespace esphome

View File

@ -1,4 +1,4 @@
#ifdef USE_ESP32_FRAMEWORK_ARDUINO #ifdef USE_ESP32_FRAMEWORK_ARDUINO
#include "LinBusListener.h" #include "LinBusListener.h"
#include "esphome/core/log.h" #include "esphome/core/log.h"
#include "driver/uart.h" #include "driver/uart.h"
@ -12,9 +12,17 @@
#endif // CUSTOM_ESPHOME_UART #endif // CUSTOM_ESPHOME_UART
#include "esphome/components/uart/uart_component_esp32_arduino.h" #include "esphome/components/uart/uart_component_esp32_arduino.h"
// Forward declarations for diag helpers
extern u_int8_t addr_parity(const u_int8_t pid);
extern u_int8_t data_checksum(const u_int8_t *message, u_int8_t length, uint16_t sum);
namespace esphome { namespace esphome {
namespace truma_inetbox { namespace truma_inetbox {
// Prototypes for helper functions in this namespace
u_int8_t addr_parity(const u_int8_t pid);
u_int8_t data_checksum(const u_int8_t *message, u_int8_t length, uint16_t sum);
static const char *const TAG = "truma_inetbox.LinBusListener"; static const char *const TAG = "truma_inetbox.LinBusListener";
#define QUEUE_WAIT_BLOCKING (portTickType) portMAX_DELAY #define QUEUE_WAIT_BLOCKING (portTickType) portMAX_DELAY
@ -39,7 +47,7 @@ void LinBusListener::setup_framework() {
uart_intr.rx_timeout_thresh = uart_intr.rx_timeout_thresh =
10; // UART_TOUT_THRESH_DEFAULT, //10 works well for my short messages I need send/receive 10; // UART_TOUT_THRESH_DEFAULT, //10 works well for my short messages I need send/receive
uart_intr.txfifo_empty_intr_thresh = 10; // UART_EMPTY_THRESH_DEFAULT uart_intr.txfifo_empty_intr_thresh = 10; // UART_EMPTY_THRESH_DEFAULT
uart_intr_config(uart_num, &uart_intr); uart_intr_config((uart_port_t) uart_num, &uart_intr);
hw_serial->onReceive([this]() { this->onReceive_(); }, false); hw_serial->onReceive([this]() { this->onReceive_(); }, false);
hw_serial->onReceiveError([this](hardwareSerial_error_t val) { hw_serial->onReceiveError([this](hardwareSerial_error_t val) {
@ -77,10 +85,47 @@ void LinBusListener::eventTask_(void *args) {
} }
} }
bool LinBusListener::write_lin_master_frame_(uint8_t pid, const uint8_t *data, uint8_t len) {
if (!this->master_mode_) return false;
if (len > 8) return false;
auto uartComp = static_cast<ESPHOME_UART *>(this->parent_);
auto uart_num = uartComp->get_hw_serial_number();
auto hw_serial = uartComp->get_hw_serial();
// Send master frame (break + sync + pid + data + crc)
uart_send_break((uint8_t) uart_num);
hw_serial->write(0x55);
uint8_t pid_with_parity = (pid & 0x3F) | (addr_parity(pid) << 6);
hw_serial->write(pid_with_parity);
uint8_t crc = data_checksum(data, len, 0);
if (len > 0) hw_serial->write((uint8_t*)data, len);
hw_serial->write(crc);
hw_serial->flush();
// For diagnostic frames (0x3C), automatically schedule slave response (0x7D)
if (pid == 0x3C) {
// Small delay to allow slave processing time
delayMicroseconds(5000); // 5ms delay
// Send response slot header (break + sync + 0x7D with parity)
uart_send_break((uint8_t) uart_num);
hw_serial->write(0x55);
uint8_t response_pid_with_parity = (0x7D & 0x3F) | (addr_parity(0x7D) << 6);
hw_serial->write(response_pid_with_parity);
hw_serial->flush();
// Note: We don't send data/CRC for response slot - slave will provide that
ESP_LOGD(TAG, "Scheduled 0x7D response slot after 0x3C diagnostic frame");
}
return true;
}
} // namespace truma_inetbox } // namespace truma_inetbox
} // namespace esphome } // namespace esphome
#undef QUEUE_WAIT_BLOCKING #undef QUEUE_WAIT_BLOCKING
#undef ESPHOME_UART #undef ESPHOME_UART
#endif // USE_ESP32_FRAMEWORK_ARDUINO #endif // USE_ESP32_FRAMEWORK_ARDUINO

View File

@ -66,9 +66,9 @@ enum class QUEUE_LOG_MSG_TYPE {
// Log messages generated during interrupt are pushed to log queue. // Log messages generated during interrupt are pushed to log queue.
struct QUEUE_LOG_MSG { struct QUEUE_LOG_MSG {
QUEUE_LOG_MSG_TYPE type; QUEUE_LOG_MSG_TYPE type;
u_int8_t current_PID; uint8_t current_PID;
u_int8_t data[9]; uint8_t data[9];
u_int8_t len; uint8_t len;
#ifdef ESPHOME_LOG_HAS_VERBOSE #ifdef ESPHOME_LOG_HAS_VERBOSE
bool current_data_valid; bool current_data_valid;
bool message_source_know; bool message_source_know;

View File

@ -0,0 +1,19 @@
#include "TriggerDiscoveryButton.h"
#include "esphome/core/log.h"
namespace esphome {
namespace truma_inetbox {
static const char *const TAG = "truma_inetbox.TriggerDiscoveryButton";
void TriggerDiscoveryButton::press_action() {
ESP_LOGI(TAG, "Discovery button pressed");
if (this->parent_ != nullptr) {
this->parent_->trigger_discovery();
} else {
ESP_LOGW(TAG, "No parent TrumaiNetBoxApp found");
}
}
} // namespace truma_inetbox
} // namespace esphome

View File

@ -0,0 +1,21 @@
#pragma once
#include "esphome/core/component.h"
#include "esphome/components/button/button.h"
#include "TrumaiNetBoxApp.h"
namespace esphome {
namespace truma_inetbox {
class TriggerDiscoveryButton : public button::Button, public Component {
public:
void set_parent(TrumaiNetBoxApp *parent) { this->parent_ = parent; }
protected:
void press_action() override;
TrumaiNetBoxApp *parent_;
};
} // namespace truma_inetbox
} // namespace esphome

View File

@ -4,6 +4,14 @@
#include "esphome/core/helpers.h" #include "esphome/core/helpers.h"
#include "helpers.h" #include "helpers.h"
#ifdef USE_ESP32
#include <sstream>
#include <sys/socket.h>
#include <netinet/in.h>
#include <fcntl.h>
#include <unistd.h>
#endif
namespace esphome { namespace esphome {
namespace truma_inetbox { namespace truma_inetbox {
@ -30,8 +38,37 @@ void TrumaiNetBoxApp::update() {
this->heater_.update(); this->heater_.update();
this->timer_.update(); this->timer_.update();
LinBusProtocol::update(); // Master mode auto-discovery (start device discovery once)
if (this->master_mode_ && !this->master_discovery_started_) {
this->master_discovery_started_ = true;
ESP_LOGI(TAG, "Master mode: Starting discovery with CORRECT CP Plus signature 17 46 10 03");
// Query identifier 0x23 with CORRECT CP Plus signature (17 46 10 03)
std::vector<uint8_t> query_23 = {0xB2, 0x23, 0x17, 0x46, 0x10, 0x03};
this->master_send_diag_single(0x7F, query_23);
this->master_send_diag_single(0x01, query_23); // Also try direct NAD
// Query identifier 0x00 with CORRECT CP Plus signature (17 46 10 03)
std::vector<uint8_t> query_00 = {0xB2, 0x00, 0x17, 0x46, 0x10, 0x03};
this->master_send_diag_single(0x7F, query_00);
this->master_send_diag_single(0x01, query_00); // Also try direct NAD
ESP_LOGI(TAG, "Master mode: Discovery sent - expecting heater response on PID 3D");
}
// UDP command receiver
this->process_udp_commands();
// Master TX scheduler (throttle to ~20ms spacing)
if (this->master_mode_ && !this->master_tx_queue_.empty()) {
uint32_t now = micros();
if (now - this->last_master_send_us_ > 20000) {
auto req = this->master_tx_queue_.front(); this->master_tx_queue_.pop();
this->write_lin_master_frame_(req.pid, req.data, req.len);
this->last_master_send_us_ = now;
}
}
#ifdef USE_TIME #ifdef USE_TIME
// Update time of CP Plus automatically when // Update time of CP Plus automatically when
// - Time component configured // - Time component configured
@ -47,6 +84,12 @@ void TrumaiNetBoxApp::update() {
} }
const std::array<uint8_t, 4> TrumaiNetBoxApp::lin_identifier() { const std::array<uint8_t, 4> TrumaiNetBoxApp::lin_identifier() {
// In master mode, identify as CP Plus instead of iNet Box
if (this->master_mode_) {
// CP Plus Combi identifier - act as the LIN master controller
return {0x17 /*Supplied Id*/, 0x46 /*Supplied Id*/, 0x00 /*Function Id*/, 0x04 /*CP Plus*/};
}
// Supplier Id: 0x4617 - Truma (Phone: +49 (0)89 4617-0) // Supplier Id: 0x4617 - Truma (Phone: +49 (0)89 4617-0)
// Unknown: // Unknown:
// 17.46.01.03 - old Combi model // 17.46.01.03 - old Combi model
@ -403,4 +446,158 @@ bool TrumaiNetBoxApp::has_update_to_submit_() {
} }
} // namespace truma_inetbox } // namespace truma_inetbox
} // namespace esphome } // namespace esphome
namespace esphome { namespace truma_inetbox {
bool TrumaiNetBoxApp::master_send_diag_single(uint8_t nad, const std::vector<uint8_t> &payload) {
if (!this->master_mode_) return false;
if (payload.size() == 0 || payload.size() > 6) return false; // SID + up to 5 bytes
MasterReq r{};
r.pid = 0x3C; // DIAGNOSTIC_FRAME_MASTER
r.len = 8;
r.data[0] = nad;
r.data[1] = (uint8_t)payload.size(); // PCI: single frame, length
// Note: using low-nibble length style is sufficient; upper nibble zero.
// payload starts at data[2]
for (size_t i = 0; i < payload.size(); i++) r.data[2+i] = payload[i];
// pad remaining bytes with 0x00
for (uint8_t i = 2 + payload.size(); i < 8; i++) r.data[i] = 0x00;
this->master_tx_queue_.push(r);
return true;
}
bool TrumaiNetBoxApp::master_scan_b2(uint8_t nad, uint8_t ident_start, uint8_t ident_end) {
if (!this->master_mode_) return false;
auto ident = this->lin_identifier();
for (uint16_t id = ident_start; id <= ident_end; id++) {
std::vector<uint8_t> pl; pl.reserve(6);
pl.push_back(0xB2); // SID Read-by-Identifier
pl.push_back((uint8_t)id); // Identifier
pl.push_back(ident[0]); // 4-byte selector
pl.push_back(ident[1]);
pl.push_back(ident[2]);
pl.push_back(ident[3]);
this->master_send_diag_single(nad, pl);
}
return true;
}
void TrumaiNetBoxApp::trigger_discovery() {
if (!this->master_mode_) {
ESP_LOGW(TAG, "Discovery can only be triggered in master mode");
return;
}
ESP_LOGI(TAG, "=== FIXED DISCOVERY SEQUENCE STARTED ===");
ESP_LOGI(TAG, "Using correct CP Plus signature 17 46 10 03 from UDP traffic analysis");
// Target heater NAD (seen responding as NAD 0x01 in traffic)
uint8_t heater_nad = 0x01;
// Also try broadcast for completeness
std::vector<uint8_t> nad_addresses = {0x7F, heater_nad};
for (uint8_t nad : nad_addresses) {
ESP_LOGI(TAG, "Sending discovery to NAD 0x%02X:", nad);
// 1. Query identifier 0x23 with CORRECT CP Plus signature (17 46 10 03)
std::vector<uint8_t> query_23 = {0xB2, 0x23, 0x17, 0x46, 0x10, 0x03};
this->master_send_diag_single(nad, query_23);
ESP_LOGI(TAG, " -> B2 23 17 46 10 03 (CP Plus sig)");
// 2. Query identifier 0x00 with CORRECT CP Plus signature (17 46 10 03)
std::vector<uint8_t> query_00 = {0xB2, 0x00, 0x17, 0x46, 0x10, 0x03};
this->master_send_diag_single(nad, query_00);
ESP_LOGI(TAG, " -> B2 00 17 46 10 03 (CP Plus sig)");
}
ESP_LOGI(TAG, "=== DISCOVERY COMPLETED - HEATER SHOULD RESPOND ON PID 3D ===");
}
void TrumaiNetBoxApp::process_udp_commands() {
if (!this->master_mode_) return;
// Initialize UDP command receiver socket on port 5556 (one port higher than stream)
if (this->udp_cmd_sock_ < 0 && !this->udp_cmd_init_attempted_) {
this->udp_cmd_init_attempted_ = true;
this->udp_cmd_sock_ = socket(AF_INET, SOCK_DGRAM, 0);
if (this->udp_cmd_sock_ < 0) {
ESP_LOGW(TAG, "UDP command socket creation failed");
return;
}
// Set non-blocking
int flags = fcntl(this->udp_cmd_sock_, F_GETFL, 0);
fcntl(this->udp_cmd_sock_, F_SETFL, flags | O_NONBLOCK);
// Bind to port 5556
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(5556);
if (bind(this->udp_cmd_sock_, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
ESP_LOGW(TAG, "UDP command socket bind failed");
close(this->udp_cmd_sock_);
this->udp_cmd_sock_ = -1;
return;
}
ESP_LOGI(TAG, "UDP command receiver listening on port 5556");
ESP_LOGI(TAG, "Send commands like: 'CMD:B2 23 17 46 10 03 NAD:01'");
}
// Check for incoming commands
if (this->udp_cmd_sock_ >= 0) {
char buffer[256];
struct sockaddr_in sender_addr;
socklen_t addr_len = sizeof(sender_addr);
ssize_t len = recvfrom(this->udp_cmd_sock_, buffer, sizeof(buffer) - 1, 0,
(struct sockaddr*)&sender_addr, &addr_len);
if (len > 0) {
buffer[len] = '\0';
std::string cmd(buffer);
ESP_LOGI(TAG, "UDP Command received: %s", cmd.c_str());
// Parse command format: "CMD:B2 23 17 46 10 03 NAD:01"
size_t cmd_pos = cmd.find("CMD:");
size_t nad_pos = cmd.find("NAD:");
if (cmd_pos != std::string::npos && nad_pos != std::string::npos) {
std::string hex_data = cmd.substr(cmd_pos + 4, nad_pos - cmd_pos - 5);
std::string nad_str = cmd.substr(nad_pos + 4);
// Parse NAD
uint8_t nad = (uint8_t)strtoul(nad_str.c_str(), nullptr, 16);
// Parse hex bytes
std::vector<uint8_t> payload;
std::istringstream iss(hex_data);
std::string byte_str;
while (iss >> byte_str) {
if (byte_str.length() == 2) {
uint8_t byte_val = (uint8_t)strtoul(byte_str.c_str(), nullptr, 16);
payload.push_back(byte_val);
}
}
if (!payload.empty()) {
ESP_LOGI(TAG, "Sending diagnostic command to NAD 0x%02X with %zu bytes", nad, payload.size());
this->master_send_diag_single(nad, payload);
} else {
ESP_LOGW(TAG, "Failed to parse command payload");
}
} else {
ESP_LOGW(TAG, "Invalid command format. Use: CMD:B2 23 17 46 10 03 NAD:01");
}
}
}
}
} }

View File

@ -1,5 +1,7 @@
#pragma once #pragma once
#include <queue>
#include "LinBusProtocol.h" #include "LinBusProtocol.h"
#include "TrumaStructs.h" #include "TrumaStructs.h"
#include "TrumaiNetBoxAppAirconAuto.h" #include "TrumaiNetBoxAppAirconAuto.h"
@ -26,6 +28,11 @@ class TrumaiNetBoxApp : public LinBusProtocol {
const std::array<u_int8_t, 4> lin_identifier() override; const std::array<u_int8_t, 4> lin_identifier() override;
void lin_heartbeat() override; void lin_heartbeat() override;
void lin_reset_device() override; void lin_reset_device() override;
// Master scanner API
bool master_send_diag_single(uint8_t nad, const std::vector<uint8_t> &payload);
bool master_scan_b2(uint8_t nad, uint8_t ident_start, uint8_t ident_end );
void trigger_discovery();
void process_udp_commands();
TRUMA_DEVICE get_heater_device() const { return this->heater_device_; } TRUMA_DEVICE get_heater_device() const { return this->heater_device_; }
TRUMA_DEVICE get_aircon_device() const { return this->aircon_device_; } TRUMA_DEVICE get_aircon_device() const { return this->aircon_device_; }
@ -75,11 +82,25 @@ class TrumaiNetBoxApp : public LinBusProtocol {
bool answer_lin_order_(const u_int8_t pid) override; bool answer_lin_order_(const u_int8_t pid) override;
bool has_update_to_submit_();
bool lin_read_field_by_identifier_(u_int8_t identifier, std::array<u_int8_t, 5> *response) override; bool lin_read_field_by_identifier_(u_int8_t identifier, std::array<u_int8_t, 5> *response) override;
const u_int8_t *lin_multiframe_recieved(const u_int8_t *message, const u_int8_t message_len, const u_int8_t *lin_multiframe_recieved(const u_int8_t *message, const u_int8_t message_len,
u_int8_t *return_len) override; u_int8_t *return_len) override;
bool has_update_to_submit_();
struct MasterReq { uint8_t pid; uint8_t len; uint8_t data[9]; };
std::queue<MasterReq> master_tx_queue_;
uint32_t last_master_send_us_ = 0;
bool master_discovery_started_ = false;
// UDP command receiver socket
int udp_cmd_sock_ = -1;
bool udp_cmd_init_attempted_ = false;
}; };
} // namespace truma_inetbox } // namespace truma_inetbox

View File

@ -1,4 +1,4 @@
from typing import Optional from typing import Optional
import esphome.codegen as cg import esphome.codegen as cg
import esphome.config_validation as cv import esphome.config_validation as cv
@ -18,8 +18,16 @@ from esphome.const import (
CONF_TRIGGER_ID, CONF_TRIGGER_ID,
CONF_STOP, CONF_STOP,
CONF_TIME_ID, CONF_TIME_ID,
CONF_TIME, CONF_TIME)
)
CONF_MASTER_MODE = "master_mode"
CONF_WRITES_ARMED = "writes_armed"
CONF_MASTER_NAD = "master_nad"
CONF_UDP_STREAM_HOST = "udp_stream_host"
CONF_UDP_STREAM_PORT = "udp_stream_port"
CONF_STREAM_ENABLED = "stream_enabled"
CONF_STREAM_DIAG_ONLY = "stream_diag_only"
CONF_STREAM_KEEPALIVE_MS = "stream_keepalive_ms"
from esphome.components.uart import ( from esphome.components.uart import (
CONF_STOP_BITS, CONF_STOP_BITS,
CONF_DATA_BITS, CONF_DATA_BITS,
@ -209,7 +217,16 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_LIN_CHECKSUM, "VERSION_2"): cv.enum(CONF_SUPPORTED_LIN_CHECKSUM, upper=True), cv.Optional(CONF_LIN_CHECKSUM, "VERSION_2"): cv.enum(CONF_SUPPORTED_LIN_CHECKSUM, upper=True),
cv.Optional(CONF_CS_PIN): pins.gpio_output_pin_schema, cv.Optional(CONF_CS_PIN): pins.gpio_output_pin_schema,
cv.Optional(CONF_FAULT_PIN): pins.gpio_input_pin_schema, cv.Optional(CONF_FAULT_PIN): pins.gpio_input_pin_schema,
cv.Optional(CONF_OBSERVER_MODE): cv.boolean, cv.Optional(CONF_OBSERVER_MODE, False): cv.boolean,
cv.Optional(CONF_UDP_STREAM_HOST): cv.string,
cv.Optional(CONF_UDP_STREAM_PORT): cv.int_,
cv.Optional(CONF_STREAM_ENABLED, False): cv.boolean,
cv.Optional(CONF_STREAM_DIAG_ONLY, False): cv.boolean,
cv.Optional(CONF_STREAM_KEEPALIVE_MS, 2000): cv.positive_int,
cv.Optional(CONF_MASTER_MODE, False): cv.boolean,
cv.Optional(CONF_WRITES_ARMED, False): cv.boolean,
cv.Optional(CONF_MASTER_NAD, 0x7F): cv.int_range(min=0, max=127),
cv.Optional(CONF_ON_HEATER_MESSAGE): automation.validate_automation( cv.Optional(CONF_ON_HEATER_MESSAGE): automation.validate_automation(
{ {
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TrumaiNetBoxAppHeaterMessageTrigger), cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TrumaiNetBoxAppHeaterMessageTrigger),
@ -257,13 +274,22 @@ async def to_code(config):
if CONF_OBSERVER_MODE in config: if CONF_OBSERVER_MODE in config:
cg.add(var.set_observer_mode(config[CONF_OBSERVER_MODE])) cg.add(var.set_observer_mode(config[CONF_OBSERVER_MODE]))
for conf in config.get(CONF_ON_HEATER_MESSAGE, []): if CONF_UDP_STREAM_HOST in config:
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) cg.add(var.set_udp_stream_host(config[CONF_UDP_STREAM_HOST]))
await automation.build_automation( if CONF_UDP_STREAM_PORT in config:
trigger, [(StatusFrameHeaterConstPtr, "message")], conf cg.add(var.set_udp_stream_port(config[CONF_UDP_STREAM_PORT]))
) if CONF_STREAM_ENABLED in config:
cg.add(var.set_stream_enabled(config[CONF_STREAM_ENABLED]))
if CONF_STREAM_DIAG_ONLY in config:
cg.add(var.set_stream_diag_only(config[CONF_STREAM_DIAG_ONLY]))
if CONF_STREAM_KEEPALIVE_MS in config:
cg.add(var.set_stream_keepalive_ms(config[CONF_STREAM_KEEPALIVE_MS]))
if CONF_MASTER_MODE in config:
cg.add(var.set_master_mode(config[CONF_MASTER_MODE]))
if CONF_WRITES_ARMED in config:
cg.add(var.set_writes_armed(config[CONF_WRITES_ARMED]))
if CONF_MASTER_NAD in config:
cg.add(var.set_master_nad(config[CONF_MASTER_NAD]))
# AUTOMATION # AUTOMATION
CONF_ENERGY_MIX = "energy_mix" CONF_ENERGY_MIX = "energy_mix"

View File

@ -0,0 +1,27 @@
import esphome.codegen as cg
import esphome.config_validation as cv
from esphome.components import button
from esphome.const import CONF_ID
from . import TrumaINetBoxApp, truma_inetbox_ns, CONF_TRUMA_INETBOX_ID
DEPENDENCIES = ["truma_inetbox"]
TriggerDiscoveryButton = truma_inetbox_ns.class_(
"TriggerDiscoveryButton", button.Button, cg.Component
)
CONFIG_SCHEMA = {
cv.GenerateID(): cv.declare_id(TriggerDiscoveryButton),
cv.GenerateID(CONF_TRUMA_INETBOX_ID): cv.use_id(TrumaINetBoxApp),
}
CONFIG_SCHEMA = button.button_schema(TriggerDiscoveryButton).extend(CONFIG_SCHEMA)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await button.register_button(var, config)
await cg.register_component(var, config)
parent = await cg.get_variable(config[CONF_TRUMA_INETBOX_ID])
cg.add(var.set_parent(parent))

85
tools/udp_cmd_sender.py Normal file
View File

@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""
UDP Command Sender for Truma ESP32 Master Mode
Sends diagnostic commands to ESP32 for dynamic testing
"""
import socket
import sys
import time
def send_command(esp_ip, command, nad):
"""Send a diagnostic command to ESP32"""
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
# Format: "CMD:B2 23 17 46 10 03 NAD:01"
message = f"CMD:{command} NAD:{nad:02X}"
print(f"Sending: {message}")
sock.sendto(message.encode(), (esp_ip, 5556))
sock.close()
def main():
if len(sys.argv) < 2:
print("Usage:")
print(" python udp_cmd_sender.py <ESP32_IP> [command] [nad]")
print("")
print("Examples:")
print(" python udp_cmd_sender.py 192.168.1.90")
print(" python udp_cmd_sender.py 192.168.1.90 'B2 23 17 46 10 03' 01")
print(" python udp_cmd_sender.py 192.168.1.90 'B2 00 17 46 10 03' 01")
print(" python udp_cmd_sender.py 192.168.1.90 'B2 00 17 46 40 03' 01")
print("")
print("Interactive mode if no command specified.")
return
esp_ip = sys.argv[1]
if len(sys.argv) >= 4:
# Single command mode
command = sys.argv[2]
nad = int(sys.argv[3], 16)
send_command(esp_ip, command, nad)
else:
# Interactive mode
print(f"UDP Command Sender - Connected to ESP32 at {esp_ip}:5556")
print("Commands will be sent to ESP32 and you should see responses on UDP port 5555")
print("")
print("Useful commands to try:")
print(" B2 23 17 46 10 03 (Query identifier 0x23 with CP Plus signature)")
print(" B2 00 17 46 10 03 (Query identifier 0x00 with CP Plus signature)")
print(" B2 00 17 46 40 03 (Query identifier 0x00 with heater signature)")
print(" B2 00 17 46 01 03 (Query identifier 0x00 with old signature)")
print("")
print("Enter 'quit' to exit")
print("")
while True:
try:
user_input = input("Enter command (hex bytes): ").strip()
if user_input.lower() in ['quit', 'exit', 'q']:
break
if not user_input:
continue
# Get NAD
nad_input = input("Enter NAD (hex, default 01): ").strip()
if not nad_input:
nad = 1
else:
nad = int(nad_input, 16)
send_command(esp_ip, user_input, nad)
print("Command sent! Check UDP receiver for responses.")
print("")
except KeyboardInterrupt:
break
except Exception as e:
print(f"Error: {e}")
print("Goodbye!")
if __name__ == "__main__":
main()

182
tools/udp_lin_receiver.py Normal file
View File

@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""
UDP receiver for LIN sniffer/master stream.
Purpose
- Capture all UDP datagrams from the ESP and write a CSV for later diffing.
- Print a live summary to the console.
- Optionally parse lines that contain textual "PID XX" + hex bytes and emit
only changes per PID (useful when the stream is verbose).
This is intentionally tolerant about payload format: it records raw hex and, if
the payload looks textual, the decoded text as well.
Examples
# Basic recording on port 5555
python udp_lin_receiver.py --port 5555 --out capture.csv
# Only show changed payloads per PID (when textual lines include "PID ..")
python udp_lin_receiver.py --port 5555 --out capture.csv --pid-diff
# Quiet mode (only CSV), capture for 5 minutes
python udp_lin_receiver.py --quiet --duration 300 --out capture.csv
"""
from __future__ import annotations
import argparse
import csv
import datetime as dt
import os
import re
import socket
import sys
import time
from typing import Dict, Optional, Tuple
def is_mostly_text(b: bytes, threshold: float = 0.9) -> bool:
if not b:
return True
printable = sum((32 <= c < 127) or c in (9, 10, 13) for c in b)
return printable / len(b) >= threshold
def to_hex(b: bytes) -> str:
return " ".join(f"{x:02X}" for x in b)
PID_LINE_RE = re.compile(r"PID\s+([0-9A-Fa-f]{2}).*?([0-9A-Fa-f]{2}(?:\s+[0-9A-Fa-f]{2})*)")
def maybe_parse_pid_and_data(text: str) -> Optional[Tuple[int, Tuple[int, ...]]]:
"""Parse lines like: 'PID 20 12 34 56 78 ...' -> (pid, (bytes...))
Returns None if no clear match.
"""
m = PID_LINE_RE.search(text)
if not m:
return None
try:
pid = int(m.group(1), 16)
data_hex = m.group(2)
data = tuple(int(x, 16) for x in data_hex.split())
return pid, data
except Exception:
return None
def now_iso() -> str:
return dt.datetime.now(dt.timezone.utc).astimezone().isoformat(timespec="milliseconds")
def run():
ap = argparse.ArgumentParser(description="UDP LIN receiver")
ap.add_argument("--host", default="0.0.0.0", help="Listen host (default: 0.0.0.0)")
ap.add_argument("--port", type=int, default=5555, help="Listen port (default: 5555)")
ap.add_argument("--out", default="udp_capture.csv", help="CSV output file path")
ap.add_argument("--append", action="store_true", help="Append to CSV if exists")
ap.add_argument("--duration", type=float, default=0.0, help="Stop after N seconds (0 = no limit)")
ap.add_argument("--quiet", action="store_true", help="No console output, only CSV")
ap.add_argument("--pid-diff", action="store_true", help="Track textual 'PID ..' lines and only print when data changes")
ap.add_argument("--suppress-repeats", action="store_true", help="Suppress repeated identical messages (shows first + count)")
ap.add_argument("--buffer", type=int, default=4096, help="Receive buffer size (default: 4096)")
args = ap.parse_args()
# Prepare CSV
out_exists = os.path.exists(args.out)
csv_mode = "a" if args.append else "w"
with open(args.out, csv_mode, newline="", encoding="utf-8") as f:
w = csv.writer(f)
if not out_exists or not args.append:
w.writerow(["ts", "src_ip", "src_port", "size", "is_text", "text", "hex"]) # header
# Socket
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((args.host, args.port))
sock.settimeout(1.0)
if not args.quiet:
print(f"Listening on {args.host}:{args.port}{args.out}")
if args.pid_diff:
print("PID diff mode: will summarize changes per PID when textual lines include 'PID ..' and hex bytes")
start = time.time()
last_by_pid: Dict[int, Tuple[int, ...]] = {}
last_message = None
repeat_count = 0
last_display_time = 0
while True:
if args.duration and (time.time() - start) >= args.duration:
if not args.quiet:
print("Duration reached; exiting.")
break
try:
data, addr = sock.recvfrom(args.buffer)
except socket.timeout:
continue
ts = now_iso()
src_ip, src_port = addr[0], addr[1]
size = len(data)
as_text = ""
is_text = False
try:
if is_mostly_text(data):
as_text = data.decode("utf-8", errors="replace").strip()
is_text = True
except Exception:
pass
hexstr = to_hex(data)
w.writerow([ts, src_ip, src_port, size, int(is_text), as_text, hexstr])
# Console output
if not args.quiet:
# Format the display message
if is_text and as_text:
display_msg = f"{ts} {src_ip}:{src_port} ({size}B) TEXT: {as_text}"
else:
display_msg = f"{ts} {src_ip}:{src_port} ({size}B) HEX: {hexstr}"
# Handle repeat suppression
if args.suppress_repeats:
current_msg_key = as_text if is_text else hexstr
current_time = time.time()
if current_msg_key == last_message:
repeat_count += 1
# Show periodic updates for long repeated sequences
if current_time - last_display_time > 5.0: # Every 5 seconds
print(f"... repeated {repeat_count} times (last: {ts})")
last_display_time = current_time
else:
# Message changed
if repeat_count > 0:
print(f"... repeated {repeat_count} times total")
print(display_msg)
last_message = current_msg_key
repeat_count = 0
last_display_time = current_time
continue
if args.pid_diff and is_text:
parsed = maybe_parse_pid_and_data(as_text)
if parsed:
pid, pdata = parsed
last = last_by_pid.get(pid)
if last != pdata:
last_by_pid[pid] = pdata
print(f"{ts} PID {pid:02X} len={len(pdata)} changed: { ' '.join(f'{b:02X}' for b in pdata) }")
continue
# default console print
print(display_msg)
if __name__ == "__main__":
try:
run()
except KeyboardInterrupt:
pass