├── .gitignore
├── CMakeLists.txt
├── app.overlay
├── prj.conf
├── J848.ldf
├── README.md
├── NOTES.md
└── src
└── main.c
/.gitignore:
--------------------------------------------------------------------------------
1 | build/*
2 |
--------------------------------------------------------------------------------
/CMakeLists.txt:
--------------------------------------------------------------------------------
1 | cmake_minimum_required(VERSION 3.20.0)
2 |
3 | find_package(Zephyr)
4 | project(preheat)
5 |
6 | target_sources(app PRIVATE src/main.c)
7 |
--------------------------------------------------------------------------------
/app.overlay:
--------------------------------------------------------------------------------
1 | / {
2 | chosen {
3 | zephyr,console = &cdc_acm_uart0;
4 | };
5 | };
6 |
7 | &zephyr_udc0 {
8 | cdc_acm_uart0: cdc_acm_uart0 {
9 | compatible = "zephyr,cdc-acm-uart";
10 | };
11 | };
12 |
13 | &fdcan1 {
14 | bitrate = <500000>;
15 | bitrate-data = <2000000>;
16 | };
17 |
--------------------------------------------------------------------------------
/prj.conf:
--------------------------------------------------------------------------------
1 | CONFIG_GPIO=y
2 |
3 | CONFIG_CAN=y
4 | CONFIG_CAN_FD_MODE=y
5 |
6 | CONFIG_USB_DEVICE_STACK=y
7 | CONFIG_USB_DEVICE_PRODUCT="Preheater USB"
8 | CONFIG_USB_DEVICE_PID=0x0004
9 | CONFIG_USB_DEVICE_INITIALIZE_AT_BOOT=n
10 |
11 | CONFIG_SERIAL=y
12 | CONFIG_CONSOLE=y
13 | CONFIG_UART_CONSOLE=y
14 |
15 | CONFIG_CBPRINTF_FP_SUPPORT=y
16 |
--------------------------------------------------------------------------------
/J848.ldf:
--------------------------------------------------------------------------------
1 |
2 | LIN_description_file;
3 | LIN_protocol_version = "2.1";
4 | LIN_language_version = "2.1";
5 | LIN_speed = 19.2 kbps;
6 |
7 | Nodes {
8 | Master: BMS, 10 ms, 0.1 ms ;
9 | Slaves: PTC ;
10 | }
11 |
12 | Signals {
13 |
14 | HvCurrent: 8, 0, PTC, BMS ;
15 | HvConnectorVoltage: 8, 0, PTC, BMS ;
16 | Status: 8, 0, PTC, BMS ;
17 | LvConnectorVoltage: 8, 0, PTC, BMS ;
18 | TempHeater: 8, 0, PTC, BMS ;
19 | TempIn: 8, 0, PTC, BMS ;
20 | TempOut: 8, 0, PTC, BMS ;
21 | Unknown1: 8, 0, BMS, PTC ;
22 | Unknown2: 10, 0, BMS, PTC ;
23 | Enable: 1, 0, BMS, PTC ;
24 | Duty: 7, 0, BMS, PTC ;
25 | Unknown3: 6, 0, BMS, PTC ;
26 | }
27 |
28 | Frames {
29 | Feedback: 15, PTC, 8 {
30 | HvCurrent, 0 ;
31 | HvConnectorVoltage, 8 ;
32 | Status, 16 ;
33 | LvConnectorVoltage, 32 ;
34 | TempHeater, 40 ;
35 | TempIn, 48 ;
36 | TempOut, 56 ;
37 | }
38 | Control: 28, BMS, 4 {
39 | Unknown1, 0 ;
40 | Unknown2, 8 ;
41 | Enable, 18 ;
42 | Duty, 19 ;
43 | Unknown3, 26;
44 | }
45 | }
46 |
47 | Node_attributes {
48 | PTC{
49 | LIN_protocol = "2.1" ;
50 | configured_NAD = 0x4 ;
51 | initial_NAD = 0x4 ;
52 | product_id = 0x0, 0x0, 0 ;
53 | P2_min = 50 ms ;
54 | ST_min = 50 ms ;
55 | N_As_timeout = 1000 ms ;
56 | N_Cr_timeout = 1000 ms ;
57 | configurable_frames {
58 | Control ;
59 | }
60 | }
61 | }
62 |
63 | Schedule_tables {
64 | Schedule_Normal {
65 | Feedback delay 100 ms ;
66 | Control delay 100 ms ;
67 | }
68 | }
69 |
70 |
71 | Signal_encoding_types {
72 | HeaterCurrent {
73 | physical_value, 0, 52, 0.25, 0, "A" ;
74 | }
75 | HeaterHighVoltage {
76 | physical_value, 0, 255, 2, 0, "V" ;
77 | }
78 | HeaterLowVoltage {
79 | physical_value, 0, 255, 0.1, 0, "V" ;
80 | }
81 | HeaterTemperature {
82 | physical_value, 0, 245, 1, -50, "degC" ;
83 | }
84 | DutyType {
85 | physical_value, 0, 100, 1, 0, "%" ;
86 | }
87 | EnableBit {
88 | physical_value, 0, 1, 1, 0, "bit" ;
89 | logical_value, 0, "Off" ;
90 | logical_value, 1, "On" ;
91 | }
92 | }
93 |
94 | Signal_representation {
95 | HeaterCurrent: HvCurrent ;
96 | HeaterHighVoltage: HvConnectorVoltage ;
97 | HeaterLowVoltage: LvConnectorVoltage ;
98 | HeaterTemperature: TempHeater, TempIn, TempOut ;
99 | EnableBit: Enable ;
100 | DutyType: Duty ;
101 | }
102 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # MEB Preheat
2 | This repository contains information on how to build a cable harness that can inject preheating messages to MEB cars, allowing for battery preheating while driving. Tested on ID.4 but should look similar on ID.3, Skoda Enyaq, ID.Buzz, Cupra Born, and others.
3 |
4 | Technokratie on Youtube made an [excellent video](https://m.youtube.com/watch?v=3ejjhapogiU) of the installation procedure. In German but english dub is available.
5 |
6 | ## Step 1
7 | Buy [a cable](https://www.aliexpress.com/item/1005008006846323.html) and a [USB2CANFDv1](https://www.aliexpress.com/item/1005007126451299.html). You will also need a ST-Link V2 (or [a clone](https://www.aliexpress.com/item/33010611392.html)) and a 1.5m USB-C to USB-C cable.
8 |
9 | ## Step 2
10 | Flash the custom firmware on the USB2CANFDv1, start by [completely emptying the flash](https://github.com/WeActStudio/WeActStudio.USB2CANFDV1?tab=readme-ov-file#how-to-completely-empty-flash), then build the code or flash the hex file in the Github releases (I'm using STM32CubeProgrammer, if you're using a clone ST-Link you might have to set "Shared" to "Enabled" and press refresh next to the serial number, then connect, open the hex file, followed by Download). Flip the CAN termination switch on the USB2CANFDv1 (marked 120Ω) to OFF.
11 |
12 | ## Step 3
13 | Cut the following strands of the aliexpress cable and plug them in accordingly on the USB2CANFDv1 screw terminal. There are pin# marking on the connector making them easy to identify.
14 |
15 | - Pin 15: CAN-L
16 | - Pin 16: CAN-H
17 | - Pin 31: GND
18 |
19 | The end product should look something like
20 |
21 | 
22 |
23 | ## Step 4
24 | Plug it into the car. The cable harness is attached between the original 40-pin gateway cable and its connector. They are located behind the glove compartment, while one could do it without removing any trim, I opt to remove the four T20 screws holding the flappy textile noise dampener to get better access. Attach the USB-C cable to the USB2CANFDv1 and guide it towards the center console.
25 |
26 | 
27 |
28 | ## Step 5
29 | You can now enable the battery heater by plugging the USB-C cable into one of the USB-C outlets in the center console. The heater will only heat the battery as long as the battery management system indicates that the battery is too cold for optimal charging performance.
30 |
--------------------------------------------------------------------------------
/NOTES.md:
--------------------------------------------------------------------------------
1 | # meb-preheat
2 | Investigating battery preheating on older MEB cars such as the ID.4. This is currently a scratchpad for my research and ideas. It does look like the J840 method using UDS output test is viable!
3 |
4 | Lots of general information available from [NHTSA](https://static.nhtsa.gov/odi/tsbs/2021/MC-10186407-0001.pdf).
5 |
6 | ## The J840 (BMS/BMCe) method
7 | I have been looking into if the J840 exposes functionality for turning on preheating by talking directly to it on the EV-CAN bus, this would require a harness between the gateway and the rest of the network (located behind the glove box).
8 |
9 | I got hold of the J840 firwmare (FL_0Z1915184J_1041_V001_S.frf). Inside is an ODX container with the AES encrypted firmware but I haven't managed to find the key.
10 |
11 | I also got a hold of the EV_BMCeVWBSMEB ODX decription of the unit but could only find the output test used by OBD11 (see below). By bypassing the firewall by connecting after the gateway, the UDS output test can be activated over the CAN-EV bus! The harness was graciously custom made by FICI Electronic Connector store on Aliexpress, they now provide [a product](https://www.aliexpress.com/item/1005008006846323.html) which one can cut to hook into the CAN-EV bus.
12 |
13 | - Pin 11: +12V
14 | - Pin 15: CAN-L (of the CAN-EV bus)
15 | - Pin 16: CAN-H (of the CAN-EV bus)
16 | - Pin 31: GND
17 |
18 | The CAN-EV bus is unfortunately named "powertrain CAN bus" in the ID.4 wiring diagram, even though it is a separate bus from the powertrain bus. To make matters even worse, the actual powertrain CAN bus is also named "powertrain CAN bus" (pin 13/14 on the gateway) in the wiring diagram.
19 |
20 | 
21 | 
22 |
23 | Triggering the heater from the CAN-EV bus works, it outputs max 5kW but throttles power to keep the heater output at 42C. "Dynamic limit for charging in amepere" stops increasing at different points, parameters unknown, sometimes at battery minimum of 21C with peak at 149.9kW (for my Wuxi battery). BMS 0% seems to correlate with 307.2V (3.2V / cell), fully charged (BMS 95.6%) is 394.75V (4.12V per cell), indicating a top buffer of 4.4% and max voltage of 4.3V. Rapidgating seems to happen at battery max temp of 40.5C.
24 |
25 | My 2022 ID.4 RWD (Wuxi battery, manufactured 2021-09, EU, v3.7) managed to receive 449.52A @ 371.5V (~165kW) when preheated to 25C battery min. SoC BMS was 19.2%, charging started at 12.4%, dynamic limit indicated 448.2A max.
26 |
27 | It seems a new 82kWh battery was introduced ~Jan 2022 for RWD MEB cars. The cars manufactured prior seem to share the battery and charging curves with the GTX models, [see comparison](https://youtu.be/Z7BFLUTt_bI?t=186).
28 |
29 | It also seems the only way to reach 175kW (470A @ 375V) is to charge when the `battery_min_temp >= 30C` but `SoC_bms < 20%`. It will only sustain those 470A while `SoC_bms < 24%`.
30 |
31 | Open questions:
32 | - Does the J840 have an external EEPROM to dump the firmware from
33 |
34 | ### Remote heating
35 | This connection could also open up for an open source app for climate control and SoC reading (i.e. and alternative to the Volkswagen app and the $$$ subscription). The bus wakes with a message on 0x1B000010 (which is one of many that control CAN sleep modes). The climate control is set using 0x16A954FB, it has 30 modes (LO, 16.0, 16.5, ..., 29.0, 29.5, HI) which can be read from:
36 |
37 | ```c
38 | uint8_t requested_cabin_temperature = ((buf[2] & 0xf) << 2) | ((buf[1] & 0xc0) >> 6)
39 | ```
40 |
41 | The message also indicates if heating is active or not:
42 | ```
43 | Heating on LO: 00 00 90 02 1A 00 00 00
44 | Heating off LO: 00 00 00 00 18 00 00 00
45 | Heating on HI: 00 40 97 02 1A 00 00 00
46 | Heating off HI: 00 40 07 00 18 00 00 00
47 | ```
48 | Heating active is also indicated in 0x16A95493, first byte is 0x01 when on and 0x00 when off
49 |
50 | ## The J533 (gateway) method
51 | The folks over at OBD11 has figured out a way to heat the battery using a UDS output test through the gateway to the battery module (J840, module 8C). This allows you to heat the battery for 5 minutes but requires your hood to be opened before being accepted. This is cool but not very practical for the purpose of preheating before DC fast charging while travelling. The output test can be re-run but the gateway firewall will lock you out after driving 200km.
52 |
53 | ## The Z132 (heater) method
54 | My initial hypothesis was to man-in-the-middle the LIN communication between the J840 and the Z132 heater, turning the heater on. This unfortunately does not trigger the BMS to activate the circulation pump, as it is controlled by the temperature reported by G898/G898 sensors on the battery coolant inlet.
55 |
56 | Relevant part showing the coolant circuit while heating the battery:
57 |
58 |
59 |
60 | The heater seems to be made by BorgWarner, and the pinout of the low voltage connector (black, connector type 1J0973714):
61 | - 1: +12V
62 | - 2: GND
63 | - 4: LIN
64 |
65 | See the ID.4 wiring diagram, available from e.g. [vwidtalk.com](https://www.vwidtalk.com/threads/repair-manual-and-all-kinds-of-id-4-information.14263).
66 |
67 |
68 |
69 | The LIN communication is documented is different from [older VW/VAG heaters](https://openinverter.org/wiki/Volkswagen_Heater#LIN_Bus_Communication).
70 |
71 | Every 50ms a PID is polled, alternating between:
72 | - ID 15 (0x0F) for feedback
73 | - Byte 1: Current (0.25*X in amps)
74 | - Byte 2: HV battery voltage (2*X in volt)
75 | - Byte 3,4: Status? Always 0
76 | - Byte 5: LV battery voltage (0.1*X in volt)
77 | - Byte 6, 7, 8: Temp heater, in, out. Offset -50
78 | - ID 28 (0x1C) for control
79 | - Byte 3, bit 6: on/off
80 | - Byte 3, bit 0-5 and byte 4 bit 0-1: duty
81 |
82 | When turning on the heater, the J840 BMS ramps the duty cycle at a rate 2% / second and stops at 99.
83 |
84 | The J848 heater also implements UDSonLIN and the car issues Read data by identifier on startup to which the heater responds:
85 | - F187: "1EE963231 "
86 | - F189: "0020"
87 | - F1A3: "H04"
88 | - F191: "1EE963231 "
89 | - F18C: "1EE96323121239000693"
90 | - F17C: "BEO-BEO27.08.2100010693"
91 | - F197: "J848 HV-PTC "
92 |
--------------------------------------------------------------------------------
/src/main.c:
--------------------------------------------------------------------------------
1 | #include
2 |
3 | #include
4 | #include
5 | #include
6 | #include
7 | #include
8 | #include
9 | #include
10 | #include
11 |
12 | BUILD_ASSERT(DT_NODE_HAS_COMPAT(DT_CHOSEN(zephyr_console), zephyr_cdc_acm_uart),
13 | "Console device is not ACM CDC UART device");
14 |
15 | uint8_t battery_heating_active;
16 | uint8_t heating_request;
17 | uint8_t cooling_request;
18 | uint8_t power_battery_heating_watt;
19 | uint8_t power_battery_heating_req_watt;
20 |
21 | uint8_t temperature_status_charge;
22 |
23 | double max_charge_power_kw;
24 | double max_charge_current_amp;
25 | double battery_min_temp;
26 | double battery_max_temp;
27 |
28 | bool session_error = false;
29 |
30 | const struct device *can_dev;
31 |
32 | static struct gpio_dt_spec led = GPIO_DT_SPEC_GET_OR(DT_ALIAS(led0), gpios,
33 | {0});
34 | static struct gpio_dt_spec rx_led = GPIO_DT_SPEC_GET_OR(DT_ALIAS(led1), gpios,
35 | {0});
36 | static struct gpio_dt_spec tx_led = GPIO_DT_SPEC_GET_OR(DT_ALIAS(led2), gpios,
37 | {0});
38 |
39 | void log_cb(const struct device *dev, struct can_frame *frame, void *user_data)
40 | {
41 | printk("Got data (%08X) (%08x):", frame->id, frame->flags);
42 | for (int i=0; idlc; ++i) {
43 | printk(" %02X", frame->data[i]);
44 | }
45 | printk("\n");
46 | }
47 |
48 | void diag_cb(const struct device *dev, struct can_frame *frame, void *user_data)
49 | {
50 | if (frame->data[0] == 0x03 &&
51 | frame->data[1] == 0x7F &&
52 | frame->data[2] == 0x2F &&
53 | frame->data[3] == 0x7F) {
54 | session_error = true;
55 | }
56 | }
57 |
58 | #define TEMPERATURE_STATUS_UNDER_OPTIMAL 1
59 |
60 | void heating_status_cb(const struct device *dev, struct can_frame *frame, void *user_data)
61 | {
62 | battery_heating_active = (frame->data[4] & 0x40) >> 6;
63 | heating_request = (frame->data[5] & 0xE0) >> 5;
64 | cooling_request = (frame->data[5] & 0x1C) >> 2;
65 | power_battery_heating_watt = frame->data[6];
66 | power_battery_heating_req_watt = frame->data[7];
67 | }
68 |
69 | void charging_optimization_cb(const struct device *dev, struct can_frame *frame, void *user_data)
70 | {
71 | //0 init, 1 temp under optimal, 2 temp optimal, 3 temp over optimal, 7 fault
72 | temperature_status_charge = (((frame->data[2] & 0x03) << 1) | frame->data[1] >> 7);
73 | }
74 |
75 | void dynamic_cb(const struct device *dev, struct can_frame *frame, void *user_data)
76 | {
77 | max_charge_power_kw = ((frame->data[7] << 5) | (frame->data[6] >> 3)) * .1;
78 | max_charge_current_amp = (((frame->data[4] & 0x3F) << 7) | (frame->data[3] >> 1)) * 0.2;
79 | }
80 |
81 | void temp_cb(const struct device *dev, struct can_frame *frame, void *user_data)
82 | {
83 | battery_max_temp = frame->data[3] * 0.5 - 40;
84 | battery_min_temp = frame->data[4] * 0.5 - 40;
85 | }
86 |
87 | void tx_irq_callback(const struct device *dev, int error, void *arg)
88 | {
89 | char *sender = (char *)arg;
90 |
91 | ARG_UNUSED(dev);
92 |
93 | if (error != 0) {
94 | printk("Callback! error-code: %d\nSender: %s\n", error, sender);
95 | }
96 | }
97 |
98 | void diag_session_handler(struct k_work *work) {
99 | struct can_frame frame = {0};
100 | frame.id = 0x17fc007b;
101 | frame.flags = CAN_FRAME_IDE | CAN_FRAME_FDF | CAN_FRAME_BRS;
102 | frame.dlc = 8;
103 | frame.data[0] = 0x02;
104 | frame.data[1] = 0x10;
105 | frame.data[2] = 0x03;
106 | frame.data[3] = 0x00;
107 | frame.data[4] = 0x00;
108 | frame.data[5] = 0x00;
109 | frame.data[6] = 0x00;
110 | frame.data[7] = 0x00;
111 | can_send(can_dev, &frame, K_MSEC(500), tx_irq_callback, NULL);
112 | }
113 |
114 | void heat_request_handler(struct k_work *work) {
115 | struct can_frame frame = {0};
116 | frame.id = 0x17fc007b;
117 | frame.flags = CAN_FRAME_IDE | CAN_FRAME_FDF | CAN_FRAME_BRS;
118 | frame.dlc = 8;
119 | frame.data[0] = 0x07;
120 | frame.data[1] = 0x2F;
121 | frame.data[2] = 0x80;
122 | frame.data[3] = 0x37;
123 | frame.data[4] = 0x03;
124 | frame.data[5] = 0x00;
125 | frame.data[6] = 0x05;
126 | frame.data[7] = 0x32;
127 | can_send(can_dev, &frame, K_MSEC(500), tx_irq_callback, NULL);
128 | }
129 |
130 | K_WORK_DEFINE(diag_session_work, diag_session_handler);
131 | K_WORK_DEFINE(heat_request_work, heat_request_handler);
132 |
133 | void state_machine_cb(struct k_timer *timer) {
134 | if (temperature_status_charge == TEMPERATURE_STATUS_UNDER_OPTIMAL) {
135 | if (session_error) {
136 | k_work_submit(&diag_session_work);
137 | printk("Got session error, switching");
138 | session_error = false;
139 | } else {
140 | printk("Sending heat request");
141 | k_work_submit(&heat_request_work);
142 | }
143 | gpio_pin_set_dt(&tx_led, 1);
144 | } else {
145 | gpio_pin_set_dt(&tx_led, 0);
146 | }
147 | }
148 |
149 | void debug_cb(struct k_timer *timer) {
150 | printk("Heating status: %d %d, %d%% (%d%%)\n", battery_heating_active, heating_request, power_battery_heating_watt, power_battery_heating_req_watt);
151 | printk("Thermal status: %d\n", temperature_status_charge);
152 | printk("Predicted power: %.1fkW (%.1fA)\n", max_charge_power_kw, max_charge_current_amp);
153 | printk("Battery temp min/max: %.1fC / %.1fC\n", battery_min_temp, battery_max_temp);
154 | printk("\n");
155 | }
156 |
157 | K_TIMER_DEFINE(state_machine_timer, state_machine_cb, NULL);
158 | K_TIMER_DEFINE(debug_timer, debug_cb, NULL);
159 |
160 | int main(void)
161 | {
162 | if (usb_enable(NULL)) {
163 | return 0;
164 | }
165 |
166 | gpio_pin_configure_dt(&led, GPIO_OUTPUT_ACTIVE);
167 | gpio_pin_configure_dt(&rx_led, GPIO_OUTPUT_INACTIVE);
168 | gpio_pin_configure_dt(&tx_led, GPIO_OUTPUT_INACTIVE);
169 |
170 | can_dev = DEVICE_DT_GET(DT_CHOSEN(zephyr_canbus));
171 |
172 | can_mode_t mode = CAN_MODE_FD;
173 | can_set_mode(can_dev, mode);
174 |
175 | can_start(can_dev);
176 |
177 | const struct can_filter diag_filter = {
178 | .flags = CAN_FILTER_IDE,
179 | .id = 0x17fe007b,
180 | .mask = CAN_EXT_ID_MASK,
181 | };
182 |
183 | const struct can_filter heating_status_filter = {
184 | .flags = CAN_FILTER_IDE,
185 | .id = 0x12DD54D2,
186 | .mask = CAN_EXT_ID_MASK,
187 | };
188 | const struct can_filter charging_optimization_filter = {
189 | .flags = CAN_FILTER_IDE,
190 | .id = 0x1A5555B2,
191 | .mask = CAN_EXT_ID_MASK,
192 | };
193 | const struct can_filter dynamic_filter = {
194 | .flags = CAN_FILTER_IDE,
195 | .id = 0x12DD54D0,
196 | .mask = CAN_EXT_ID_MASK,
197 | };
198 | const struct can_filter temp_filter = {
199 | .flags = CAN_FILTER_IDE,
200 | .id = 0x16A954A6,
201 | .mask = CAN_EXT_ID_MASK,
202 | };
203 |
204 | can_add_rx_filter(can_dev, diag_cb, NULL, &diag_filter);
205 | can_add_rx_filter(can_dev, heating_status_cb, NULL, &heating_status_filter);
206 | can_add_rx_filter(can_dev, charging_optimization_cb, NULL, &charging_optimization_filter);
207 | can_add_rx_filter(can_dev, dynamic_cb, NULL, &dynamic_filter);
208 | can_add_rx_filter(can_dev, temp_cb, NULL, &temp_filter);
209 |
210 | k_timer_start(&debug_timer, K_MSEC(1000), K_MSEC(1000));
211 | k_timer_start(&state_machine_timer, K_MSEC(250), K_MSEC(500));
212 |
213 | while (1)
214 | {
215 | gpio_pin_set_dt(&led, 0);
216 | k_msleep(800);
217 | gpio_pin_set_dt(&led, 1);
218 | k_msleep(200);
219 | }
220 |
221 | return 0;
222 | }
223 |
--------------------------------------------------------------------------------