Compare commits

11 Commits

8 changed files with 364 additions and 4 deletions

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 uartComp = static_cast<ESPHOME_UART *>(this->parent_);
auto uart_num = uartComp->get_hw_serial_number(); auto uart_num = uartComp->get_hw_serial_number();
auto hw_serial = uartComp->get_hw_serial(); auto hw_serial = uartComp->get_hw_serial();
// Send master frame (break + sync + pid + data + crc)
uart_send_break((uint8_t) uart_num); uart_send_break((uint8_t) uart_num);
hw_serial->write(0x55); hw_serial->write(0x55);
uint8_t pid_with_parity = (pid & 0x3F) | (addr_parity(pid) << 6); 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); if (len > 0) hw_serial->write((uint8_t*)data, len);
hw_serial->write(crc); hw_serial->write(crc);
hw_serial->flush(); 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; 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 "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,6 +38,28 @@ void TrumaiNetBoxApp::update() {
this->heater_.update(); this->heater_.update();
this->timer_.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) // Master TX scheduler (throttle to ~20ms spacing)
if (this->master_mode_ && !this->master_tx_queue_.empty()) { if (this->master_mode_ && !this->master_tx_queue_.empty()) {
uint32_t now = micros(); uint32_t now = micros();
@@ -54,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
@@ -446,4 +482,122 @@ bool TrumaiNetBoxApp::master_scan_b2(uint8_t nad, uint8_t ident_start, uint8_t i
return true; 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 // Master scanner API
bool master_send_diag_single(uint8_t nad, const std::vector<uint8_t> &payload); 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 ); 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_; }
@@ -93,6 +95,11 @@ class TrumaiNetBoxApp : public LinBusProtocol {
struct MasterReq { uint8_t pid; uint8_t len; uint8_t data[9]; }; struct MasterReq { uint8_t pid; uint8_t len; uint8_t data[9]; };
std::queue<MasterReq> master_tx_queue_; std::queue<MasterReq> master_tx_queue_;
uint32_t last_master_send_us_ = 0; 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("--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("--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("--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)") ap.add_argument("--buffer", type=int, default=4096, help="Receive buffer size (default: 4096)")
args = ap.parse_args() args = ap.parse_args()
@@ -101,6 +102,9 @@ def run():
start = time.time() start = time.time()
last_by_pid: Dict[int, Tuple[int, ...]] = {} last_by_pid: Dict[int, Tuple[int, ...]] = {}
last_message = None
repeat_count = 0
last_display_time = 0
while True: while True:
if args.duration and (time.time() - start) >= args.duration: if args.duration and (time.time() - start) >= args.duration:
@@ -129,6 +133,33 @@ def run():
# Console output # Console output
if not args.quiet: 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: if args.pid_diff and is_text:
parsed = maybe_parse_pid_and_data(as_text) parsed = maybe_parse_pid_and_data(as_text)
if parsed: if parsed:
@@ -140,10 +171,7 @@ def run():
continue continue
# default console print # default console print
if is_text and as_text: print(display_msg)
print(f"{ts} {src_ip}:{src_port} ({size}B) TEXT: {as_text}")
else:
print(f"{ts} {src_ip}:{src_port} ({size}B) HEX: {hexstr}")
if __name__ == "__main__": if __name__ == "__main__":