Compare commits

13 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
10 changed files with 399 additions and 6 deletions

View File

@@ -336,13 +336,14 @@ void LinBusListener::read_lin_frame_() {
TRUMA_LOGV_ISR(log_msg);
#endif // ESPHOME_LOG_HAS_VERBOSE
if (this->current_data_valid && message_from_master) {
if (this->current_data_valid) {
QUEUE_LIN_MSG lin_msg;
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++) {
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);
}
this->current_state_ = READ_STATE_BREAK;
@@ -358,6 +359,10 @@ void LinBusListener::clear_uart_buffer_() {
void LinBusListener::process_lin_msg_queue(TickType_t xTicksToWait) {
QUEUE_LIN_MSG lin_msg;
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);
}
}
@@ -438,6 +443,32 @@ void LinBusListener::process_log_queue(TickType_t xTicksToWait) {
}
#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_();

View File

@@ -33,6 +33,7 @@ struct QUEUE_LIN_MSG {
u_int8_t current_PID;
u_int8_t data[8];
u_int8_t len;
u_int8_t from_master; // 1 = master order, 0 = slave response
};
class LinBusListener : public PollingComponent, public uart::UARTDevice {
@@ -151,6 +152,7 @@ class LinBusListener : public PollingComponent, public uart::UARTDevice {
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)];
StaticQueue_t lin_msg_static_queue_;

View File

@@ -92,6 +92,8 @@ bool LinBusListener::write_lin_master_frame_(uint8_t pid, const uint8_t *data, u
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);
@@ -100,6 +102,23 @@ bool LinBusListener::write_lin_master_frame_(uint8_t pid, const uint8_t *data, u
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;
}

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 "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 truma_inetbox {
@@ -30,6 +38,28 @@ void TrumaiNetBoxApp::update() {
this->heater_.update();
this->timer_.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();
@@ -54,6 +84,12 @@ void TrumaiNetBoxApp::update() {
}
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)
// Unknown:
// 17.46.01.03 - old Combi model
@@ -446,4 +482,122 @@ bool TrumaiNetBoxApp::master_scan_b2(uint8_t nad, uint8_t ident_start, uint8_t i
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

@@ -31,6 +31,8 @@ class TrumaiNetBoxApp : public LinBusProtocol {
// 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_aircon_device() const { return this->aircon_device_; }
@@ -93,6 +95,11 @@ class TrumaiNetBoxApp : public LinBusProtocol {
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;
};

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()

View File

@@ -77,6 +77,7 @@ def run():
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()
@@ -101,6 +102,9 @@ def run():
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:
@@ -129,6 +133,33 @@ def run():
# 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:
@@ -140,10 +171,7 @@ def run():
continue
# default console print
if is_text and as_text:
print(f"{ts} {src_ip}:{src_port} ({size}B) TEXT: {as_text}")
else:
print(f"{ts} {src_ip}:{src_port} ({size}B) HEX: {hexstr}")
print(display_msg)
if __name__ == "__main__":