Load for injector banks in stock ECU

Is this 2 injector banks load going to handle it stock engine ECU running when ignition KV load will be applied ?

Make the ECU’s sense circuit see an RL load resistive + inductive so the current rise and fall and flyback transient look more like a real injector. That reduces chance of diagnostics and FAULTs and makes behaviour realistic.

Basic idea:

Put a series inductor choke in series with our dummy resistors for each injector bank. We want an inductance L large enough so the current rise time is similar to a real injector.

Design target and easy rule:

Typical target time constant τ 63% current for an injector bank: ~2–6 ms.

Formula: L = R × τ where R is the DC resistance of your bank.

For our injector bank bank R ≈ 5 Ω 3 × 15 Ω in parallel choose τ = 4 ms →
L = 5 Ω × 0.004 s = 0.02 H = 20 mH.

So a 10–30 mH inductor is a good practical range. Aim ~20 mH as a sweet spot.

Thank you in advance for your advice !

ECU output pin 16 or 17 of each injector bank.

+ ─ L = 30 mH ── + ────────── node bank
│ │
│ ── 15 Ω ──┐
│ │ └─ to ground
│ ── 15 Ω ──┐
│ │ └─ to ground
│ └── 15 Ω ──┐
│ └─ to ground

across the three 15Ω resistors:
node ─── 100 Ω ─── + ──||─── ground
series 0.1 µF

Why not just use injectors, that is what we used?

2 Likes

Why not just use injectors that is what we used ? But to use injectors we have to buy 6 injectors and have them placed and secure it in safe place. The dummy load with 30 mH inductor and resistors with capacitors will can make tiny box and place it in the good spot and just run it 2 wires for it . Two wires for the stock ECU.

Short answer: yes a properly built dummy-load box 3×15 Ω/20 W resistors per bank + a 20–30 mH series choke + the 100 Ω/0.1 µF snubber is usually the better practical choice for bench testing the ECU. It’s cheaper smaller easier to mount and route just two wires per bank and safer than mounting six real injectors. But there are tradeoffs so here’s a compact practical comparison and a build checklist we can use.

Pros vs. using real injectors

Dummy-load box recommended.

Compact: fits in a small metal box two-wire connection to each ECU output.

Cheaper: resistors + chokes cost of 6 injectors and fittings.

Safer and convenient: no fuel no mounting near hot engine parts.

Tunable: we can select inductance and resistance to match ECU sensing.

Predictable heating and protection: easier to fuse and ventilate.

Real injectors:

Best electrical match real inductance and manufacturing tolerances.

If we need mechanical and flow testing we must use them.

More work: mount securely plumbing sealing space and expense.

Important caveats why a dummy must be done right.

ECU diagnostics sometimes check coil inductance and flyback signature. A pure resistor can fail those checks. Adding a series inductor 20–30 mH makes the waveform look much closer to a real coil and typically avoids false faults.

Resistive dummy dissipates real heat ~9–10 W per resistor at 12 V for our 3×15Ω injector bank. We must cool and mount the resistors properly.

Make sure the snubber stays across the bank terminals to shape the flyback and protect the ECU.

Recommended design per one injector bank:

  • Resistive load: 3 × 15 Ω 20 W resistors in parallel → ~5 Ω total.

  • Inductor series: L = 20–30 mH rated ≥ 3–4 A DC continuous DCR < 0.5 Ω if possible. 20 mH gives τ ≈ 4 ms with R=5 Ω: 30 mH is slightly slower.

  • RC snubber: 100 Ω in series with 0.1 µF connected across the injectors bank node ECU return. Use a resistor power rating appropriate for pulses 2–5 W is fine: snubber resistor rarely sees full continuous power.

  • Fusing: 4 A slow-blow fuse per bank or a 3 A slow-blow if we want tighter protection. This protects against shorts while giving margin for the ~2.4 A normal current.

  • Wiring: use 16 AWG wire for the two ECU wires per bank able to carry a few amps comfortably. Keep wiring short.

  • Connector: use a reliable 2-pin automotive connector that mates the ECU harness or clamp to the original pins for bench testing.

  • Enclosure: metal box with ventilation holes, or bolt resistors to a small heatsink. Keep inductors and capacitors away from each other to avoid magnetics coupling and mechanical stress.

Parts checklist practical

Per bank:

3 × 15 Ω 20 W wirewound resistors prefer ceramic and insulated body.

1 × 20–30 mH power choke 3–5 A rating low DCR

1 × 100 Ω resistor 2–5 W for snubber series with the cap

1 × 0.1 µF 50–100 V film or X7R ceramic capacitor

1 × inline fuse holder + fuse 4 A slow-blow recommended

2-pin connector or ring and lug terminals to tap ECU outputs

Small metal enclosure screws thermal pad and heatsink material

Short lengths 16 AWG wire heat shrinks terminal blocks or solder lugs

Assembly & wiring step-by-step.

Mount the three 15 Ω resistors in parallel on a heatsink or insulated standoffs inside the metal box. Tie their one ends together to form the bank node: tie the other ends to ground and return.

Place the single series inductor between the incoming ECU pin external connector and the injectors bank node.

Wire the RC snubber across the bank node and ground: 100 Ω in series with 0.1 µF both between node and ground.

Put an inline fuse in the ECU feed line before the inductor.

Use secure screw terminals or soldered lugs for connections: use heat-shrink and strain relief on the external cable.

Label the box: Bank A and Bank B fuse rating polarity and connector pin.

Vent the enclosure or bolt resistors to a heatsink: don’t enclose resistors in a tiny closed plastic box without ventilation.

First-power testing.

Use a current-limited bench supply or install the recommended fuse.

With the ECU connected command injectors or cycle outputs at low duty first and monitor temperatures.

If possible use a scope to check the injectors bank waveform we should see a ramp during ON and a shaped flyback on OFF that resembles an injector coil. If ECU still flags a fault increase L slightly or try using a single real injector in the bank to confirm.

When to choose real injectors anyway ?

If we are validating flow and fuel mapping or mechanical mounting or the ECU uses particularly strict coil signature checks that we can’t match with an inductor buy real injectors. Otherwise the dummy box is the pragmatic choice.

If you want I can:

draw a final clean schematic PNG with both banks correctly wired 30 mH series 3×15Ω to ground snubber fuse and connector symbols or produce a one-page printable parts + wiring checklist we can use when building the box then you confirm is good to go or not.

Which would you prefer ? I would prefer simulator dummy box which will be keeping stock ECU working as should.

Too many words as for me... Do you have a question?
Otherwise Is the message a just a spam?

1 Like

The question is what MCU board would you use to work 3 injector banks sequentially in 6 cylinder engine. Each injector bank has 2 injectors wired together at negative terminals. Pulse width will be working from TPS. Voltage increases on TPS then pulse width increase.

            Circuit board design

We could design circuit board which will control 3 injector banks sequentially. It’s line six cylinder engine M30B30/B35 BMW E32 1986-1990 but we don’t have to design it is already ready and design done right we just have to write the code for it and we good to go. Each 2 injectors has to be controlled individually by microcontroller by individual terminal. The 6 injectors is split it by two and wired each 2 injectors together on negative terminal and have to be controlled by negative terminal of the microcontroller. All 6 injectors will be powered by 12 volts but 3 injector banks will be wired by negative terminal only. 3 injector banks means each 2 injectors negative terminal wired together.

Engine and injector setup:

Engine: 6-cylinder 4-stroke firing order 1‑5‑3‑6‑2‑4

Injector banks:
Bank A: cylinders 1 & 5

Bank B: cylinders 3 & 6

Bank C: cylinders 2 & 4

Each bank fires both its injectors simultaneously whenever one of its cylinders reaches intake stroke start.

Implication of 2-injector-per-bank firing.

Cylinder 1 starts intake stroke → Bank A fires → injectors for cylinder 1 and 5 inject at the same time.

Cylinder 3 starts intake stroke → Bank B fires → injectors for cylinder 3 and 6 inject simultaneously.

Cylinder 2 starts intake stroke → Bank C fires → injectors for cylinder 2 and 4 inject simultaneously.

So injection order by cylinder in the first 720°:

Cylinders 1 and 5 Bank A → 0 ms

Cylinders 3 and 6 Bank B → 53.4 ms

Cylinders 2 and 4 Bank C → 106.8 ms

So means every 53.3 milliseconds each injector bank gets fuel injected into combustion chamber at RPM750 ?

Let’s make it crystal clear.

Engine setup recap.

Engine: 6-cylinder 4-stroke

Injector banks:

Bank A → cylinders 1 and 5

Bank B → cylinders 3 and 6

Bank C → cylinders 2 and 4

Engine speed = 750 RPM → 1 rev = 80 ms → 720° = 160 ms per 4-stroke cycle

Cylinder-to-cylinder spacing = 120° → 26.7 ms

Each injector bank fires both its injectors simultaneously whenever one of its cylinders reaches intake stroke start.

Injector bank firing timing

From previous calculation:

Time in ms. Injector Bank. Cylinders Injected.

0.0 A 1 and 5

53.4 B 3 and 6

106.8 C 2 and 4

160.0 A next cycle 1 and 5

Notice the time between bank firings in the first 720° cycle = 53.4 ms

Interpretation

Bank A fires at 0 ms → injects fuel into cylinders 1 and 5

Bank B fires 53.4 ms later → injects fuel into cylinders 3 and 6

Bank C fires 53.4 ms after that → injects fuel into cylinders 2 and 4

Then the cycle repeats at 160 ms → Bank A fires again

Conclusion:

At RPM 750 each injector bank fires approximately every 53.4 ms sequentially in a 720° cycle.

Each firing injects fuel into both cylinders of that injector bank simultaneously.

Important: The actual injection pulse duration how long the injectors are open is much shorter typically 1–10 ms — the 53.4 ms is the spacing between bank firings not the injection duration itself.

If we want we can draw a simple timeline diagram showing the 3 injector banks firing sequentially at 53.4 ms intervals which makes it visually obvious.

0 ms 53.4 ms 106.8 ms 160 ms next cycle

|---------|----------|---------------|

Bank A Bank B Bank C Bank A cycle repeats

1 and 5 3 and 6 2 and 4 1 and 5

Nice this is a clear practical project. Below I’ll give us a complete ready-to-implement design plan for a 3-bank sequential injector controller for the BMW M30B35 E32 1990 with:

full electrical schematic description power drivers protection sensors

recommended components BOM and footprints

PCB and layout notes

connector pinouts

MCU choice + pin mapping

safety and diagnostic features

timing and control algorithm with the math we specified and example pseudo-code and fuel-map approach

current and fuse calculations and thermal notes.

We won’t ask for clarifications. We’ll make reasonable choices based on standard engine-injector practice. If anything we want changed different MCU specific MOSFET or to add CAN and OBD tell me and we’ll adapt.

Summary of requirements restated for clarity

6 injectors: they are wired as 3 banks each bank = two injectors sharing negative terminal. All injectors share +12 V supply.

The controller switches the negative side of each bank individually low-side switching.

Firing order bank-level sequence: 1–5 3–6 2–4 repeated injector banks 1-5 every 53.4ms then 3-6 every 53.4ms then 2-4 every 53.4ms and so on going on at RPM750. So if increased RPM1500=we use formula 53.4:2=26.7ms. So as RPM increases up to 2250 we divide 26.7:2=13.35ms we going to run injection into combustion chamber by each injector bank every 13.35 milliseconds at RPM2250. Last step we can add is RPM3000 it’s going to be enough for it no need more in automatic transmission.

RPM3000=6.675ms each injector bank should be on and off.

Engine speed = 750 RPM → 1 rev = 80 ms → 720° = 160 ms per 4-stroke cycle

Cylinder-to-cylinder spacing = 120° → 26.7 ms

Example: at RPM750=360 degrees one revolution takes 80 ms: at RPM1500 takes 40 ms/rev

Pulse width depends on TPS and RPM user gave example points. RPM will not be used to adjust fuel pulse width.

Title: Can Arduino Mega control 3 injector banks sequentially with crank & cam sensor ?

Hi everyone !

I’m experimenting with fuel injector control on a BMW M30B35 engine and I’d like some advice on whether the Arduino Mega is capable of handling this.

The setup:

6-cylinder, 4-stroke engine

3 injector banks 2 injectors per bank

Firing order: 1-5-3-6-2-4

Crankshaft wheel: 60-2 58 teeth per revolution

Camshaft sensor CID: one pulse per 720° crank rotation triggered at secondary ignition spark plug wire on cylinder number 6.

Questions:

Can the Arduino Mega handle controlling 3 injector banks sequentially Injector Bank A = 1&5 Injector Bank B = 3&6 Injector Bank C = 2&4 using the 60-2 crank wheel for timing ?

At 750 RPM each injector bank needs to fire every ~53 ms 720° = 160 ms total cycle.

I’d like to know if the Mega’s timers and interrupts are fast and accurate enough for this.

How should I add the camshaft sensor one pulse per 720° at cylinder #6 into the code ?

My understanding: the crank sensor gives fine angle resolution but the cam sensor is needed to distinguish compression vs exhaust stroke and gives a small AC signal around 0.25-1.5 volts only.

Should I sync the tooth counter with the cam pulse minus the 12° BTDC offset then schedule bank injections at the right teeth 37 74 110 ?

I’m mainly learning so this doesn’t need to be production-ready but I’d like to know whether Arduino Mega is realistic for this task or if I should move to STM32 and Teensy.

Thanks in advance for any advice !

1 Like

We asked our customer for them and they supplied them no charge.

When I worked on fuel injectors, they were not driven by simple on/off
pulses, the output was a shaped profile. My understanding is that the
stock ECU provides the injector drive signals, but without knowing your
exact setup or having a schematic, it is difficult to be certain. Since
this appears to be your first attempt, I recommend studying more closely
how injectors are operated in real practice.

As far as processor it is dependent on what it has to do.

FYI: Arduinos are designed for experimentation and learning, often used with breadboards and loose wires, which can become unreliable if vibrated. They are not built for harsh, dirty, or electrically noisy environments commonly found in industrial, automotive, or other commercial applications, making them unsuitable for such settings.

Not driven by simple on and off pulses shaped profile.
In many stock ECUs injectors aren’t just switched fully ON and OFF like a mechanical relay.
Instead the ECU uses a shaped current waveform:

Peak hold: high initial current to quickly open the injector needle.

Hold: lower current to keep the injector open for the rest of the pulse.
This reduces coil heating and improves timing precision.
So even if the injector is on for say 5 ms at idle is a bit longer than normal the current is not a flat square pulse it rises quickly holds at a lower level then falls.

This is different from a naïve microcontroller on and off pulse where we simply put voltage on the injector for the entire pulse duration.

Key point: Shaped driving is about how the current through the injector coil behaves not which injector fires first.

Sequential firing of injector banks
Sequential means injector banks or individual injectors are activated in a timed sequence relative to the engine cycle.
Example in our M30B35 batch and dual injector setup:
Bank A cylinders 1 & 5 fires first at degrees 352°
Bank B cylinders 3 & 6 fires next at degrees 592°

Bank C cylinders 2 & 4 fires last at degrees 832° mod720 = 112°

720 degrees reset to zero.

Absolute angles and forward angles from the trigger:

Bank A absolute = trigger + 0° = 352° → forward from trigger = 0° → delay = 0.000 ms → tooth_index = 0.000

Bank B absolute = trigger + 240° = 352 + 240 = 592° → forward = 240° → delay = 53.333 ms → tooth_index = 38.6667

Bank C absolute = trigger + 480° = 352 + 480 = 832° → mod720 = 112° → forward = 480° → delay = 106.667 ms → tooth_index = 77.3333

Other injector banks are off while one bank is injecting that’s what people often call sequential or banked injection.

This is about timing and order not the shape of the pulse. The pulse could still be square shaped or even PWM modulated.

Putting it together:
Concept Focus:

Typical implementation:

Not driven by simple on and off pulses.
Current waveform shape.
ECU shapes current through injector peak hold to improve response and reduce heat.

Sequential banked firing.
Which injector fires when.
Only one injector bank fires at a time in proper engine order. Pulse can be flat or shaped.

Bottom line:
Shape = how the injector coil is energized during a single injection.
Sequential and banked = which injectors are energized at any given moment relative to engine rotation.

We can have sequential banks using shaped pulses or simple on and off pulses. The sequence is independent of the current shape.

1 Like

Here is build a complete STM32 HAL-ready code for the M30B35 sequential injector control integrating:
3 injector banks injector bank A=1&5 injector bank B=3&6 injector bank C=2&4

TPS-PulseWidth mapping
Sequential injection every 53.3 ms 240° degrees
Cylinder ID sensor CID sync
Missing tooth crank sync 60–2 wheel 58 teeth per rev
Reset after 720° degrees

Here is provided a fully commented example in C using STM32 HAL. We can adjust pins timers and ADC channels for our setup.

So in this case we don’t need O2 sensor. TPS will handle it fuelling isn’t ?

In this simplified setup TPS alone can determine fuel pulse width because the code we now have maps TPS voltage and injector pulse width.

TPS measures throttle opening then acts as a proxy for engine load.
Pulse width table then defines how long to open the injectors at each TPS voltage.

No closed-loop feedback the system is purely open-loop.

So we don’t need an O₂ sensor for this to work but:

Caveats:
Fuel mixture accuracy is limited:
At varying RPM temperature or engine load TPS-only control can lead to lean or rich conditions.

O₂ sensor allows closed-loop correction to keep the mixture stoichiometric.

Cold start and warmup:
Without an O₂ sensor, we must manually increase injector pulse widths for cold starts.

Engine tuning:
If we want high efficiency or emissions control TPS-only open-loop is less accurate.

Summary: TPS will handle fuelling for basic sequential injection but it’s not as precise as using an O₂ sensor for closed-loop correction.

So we can modify our code to optionally include an O₂ sensor for closed-loop pulse width correction while still keeping TPS mapping as the base. This would make it much more ECU-like.

Could someone confirm if this code going to work ? Thanks in advance for your feedback !

#include "stm32f1xx_hal.h"
#include <stdint.h>

#define NUM_BANKS       3
#define MICROSEC_PER_MS 1000

// --- GPIO pins for injector banks ---
#define INJ_BANK_A_PORT GPIOA
#define INJ_BANK_A_PIN  GPIO_PIN_0
#define INJ_BANK_B_PORT GPIOA
#define INJ_BANK_B_PIN  GPIO_PIN_1
#define INJ_BANK_C_PORT GPIOA
#define INJ_BANK_C_PIN  GPIO_PIN_2

// --- CID sensor pin ---
#define CID_PIN         GPIO_PIN_3
#define CID_PORT        GPIOB

// --- Crankshaft sensor pin ---
#define CRK_PIN         GPIO_PIN_4
#define CRK_PORT        GPIOB

// --- TPS ADC channel ---
#define TPS_ADC_CHANNEL ADC_CHANNEL_0

// --- Injector timing ---
#define INJ_DELAY_US    53300UL  // 53.3 ms between banks

// --- TPS → Pulse width map (ms) ---
#define TPS_BINS_N 100
static const float tps_volts[TPS_BINS_N] = {
  0.050,0.100,0.150,0.200,0.250,0.300,0.350,0.400,0.450,0.500,
  0.550,0.600,0.650,0.700,0.750,0.800,0.850,0.900,0.950,1.000,
  1.050,1.100,1.150,1.200,1.250,1.300,1.350,1.400,1.450,1.500,
  1.550,1.600,1.650,1.700,1.750,1.800,1.850,1.900,1.950,2.000,
  2.050,2.100,2.150,2.200,2.250,2.300,2.350,2.400,2.450,2.500,
  2.550,2.600,2.650,2.700,2.750,2.800,2.850,2.900,2.950,3.000,
  3.050,3.100,3.150,3.200,3.250,3.300,3.350,3.400,3.450,3.500,
  3.550,3.600,3.650,3.700,3.750,3.800,3.850,3.900,3.950,4.000,
  4.050,4.100,4.150,4.200,4.250,4.300,4.350,4.400,4.450,4.500,
  4.550,4.600,4.650,4.700,4.750,4.800,4.850,4.900,4.950
};

static const float tps_pw_ms[TPS_BINS_N] = {
  1.5,1.5,1.6,1.6,1.7,1.7,1.8,1.8,1.9,1.9,
  2.0,2.0,2.1,2.1,2.2,2.2,2.3,2.3,2.4,2.4,
  2.5,2.5,2.6,2.6,2.7,2.7,2.8,2.8,2.9,2.9,
  3.0,3.0,3.1,3.1,3.2,3.2,3.3,3.3,3.4,3.4,
  3.5,3.5,3.6,3.6,3.7,3.7,3.8,3.8,3.9,3.9,
  4.0,4.0,4.1,4.1,4.2,4.2,4.3,4.3,4.4,4.4,
  4.5,4.5,4.6,4.6,4.7,4.7,4.8,4.8,4.9,4.9,
  5.0,5.0,5.1,5.1,5.2,5.2,5.3,5.3,5.4,5.4,
  5.5,5.5,5.6,5.6,5.7,5.7,5.8,5.8,5.9,5.9,
  6.0,6.0,6.1,6.1,6.2,6.2,6.3,6.3,6.4,6.4
};

// --- Global variables ---
volatile uint32_t tooth_count = 0;
volatile int current_bank = 0; // 0=A,1=B,2=C
TIM_HandleTypeDef htim_pw;
TIM_HandleTypeDef htim_sched;
ADC_HandleTypeDef hadc1;

// --- Function prototypes ---
float read_tps_voltage(void);
uint32_t get_pw_us(float tps_voltage);
void fire_bank(int bank);
void stop_bank(int bank);
void schedule_next_injection(void);

// --- Fire/stop injectors ---
void fire_bank(int bank){
    switch(bank){
        case 0: HAL_GPIO_WritePin(INJ_BANK_A_PORT, INJ_BANK_A_PIN, GPIO_PIN_SET); break;
        case 1: HAL_GPIO_WritePin(INJ_BANK_B_PORT, INJ_BANK_B_PIN, GPIO_PIN_SET); break;
        case 2: HAL_GPIO_WritePin(INJ_BANK_C_PORT, INJ_BANK_C_PIN, GPIO_PIN_SET); break;
    }
}

void stop_bank(int bank){
    switch(bank){
        case 0: HAL_GPIO_WritePin(INJ_BANK_A_PORT, INJ_BANK_A_PIN, GPIO_PIN_RESET); break;
        case 1: HAL_GPIO_WritePin(INJ_BANK_B_PORT, INJ_BANK_B_PIN, GPIO_PIN_RESET); break;
        case 2: HAL_GPIO_WritePin(INJ_BANK_C_PORT, INJ_BANK_C_PIN, GPIO_PIN_RESET); break;
    }
}

// --- TPS voltage → pulse width interpolation ---
uint32_t get_pw_us(float tps_voltage){
    if(tps_voltage <= tps_volts[0]) return (uint32_t)(tps_pw_ms[0]*1000);
    if(tps_voltage >= tps_volts[TPS_BINS_N-1]) return (uint32_t)(tps_pw_ms[TPS_BINS_N-1]*1000);

    int i;
    for(i=0;i<TPS_BINS_N-1;i++){
        if(tps_voltage <= tps_volts[i+1]) break;
    }
    float t = (tps_voltage - tps_volts[i]) / (tps_volts[i+1]-tps_volts[i]);
    float pw = tps_pw_ms[i] + t*(tps_pw_ms[i+1]-tps_pw_ms[i]);
    return (uint32_t)(pw*1000.0f + 0.5f);
}

// --- Schedule next injection ---
void schedule_next_injection(void){
    float tps_voltage = read_tps_voltage();
    uint32_t pw_us = get_pw_us(tps_voltage);

    fire_bank(current_bank);

    // Start pulse width timer
    __HAL_TIM_SET_COUNTER(&htim_pw, 0);
    __HAL_TIM_SET_AUTORELOAD(&htim_pw, pw_us-1);
    HAL_TIM_Base_Start_IT(&htim_pw);

    // Next bank
    current_bank = (current_bank + 1) % NUM_BANKS;

    // Schedule next bank after 53.3 ms
    __HAL_TIM_SET_COUNTER(&htim_sched, 0);
    __HAL_TIM_SET_AUTORELOAD(&htim_sched, INJ_DELAY_US-1);
    HAL_TIM_Base_Start_IT(&htim_sched);
}

// --- CID ISR ---
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin){
    if(GPIO_Pin == CID_PIN){
        tooth_count = 0;
        current_bank = 0; // Bank A first
        schedule_next_injection();
    }
}

// --- Crankshaft tooth ISR ---
void HAL_GPIO_EXTI_Callback_Crank(uint16_t GPIO_Pin){
    if(GPIO_Pin == CRK_PIN){
        tooth_count++;
        if(tooth_count >= 58) tooth_count = 0;
    }
}

// --- Timer callbacks ---
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim){
    if(htim == &htim_pw){
        int off_bank = (current_bank + NUM_BANKS -1) % NUM_BANKS;
        stop_bank(off_bank);
        HAL_TIM_Base_Stop_IT(&htim_pw);
    }
    else if(htim == &htim_sched){
        schedule_next_injection();
        HAL_TIM_Base_Stop_IT(&htim_sched);
    }
}

// --- ADC read ---
float read_tps_voltage(void){
    ADC_ChannelConfTypeDef sConfig = {0};
    sConfig.Channel = TPS_ADC_CHANNEL;
    sConfig.Rank = ADC_REGULAR_RANK_1;
    sConfig.SamplingTime = ADC_SAMPLETIME_28CYCLES_5;
    HAL_ADC_ConfigChannel(&hadc1, &sConfig);

    HAL_ADC_Start(&hadc1);
    HAL_ADC_PollForConversion(&hadc1, 10);
    uint32_t raw = HAL_ADC_GetValue(&hadc1);
    HAL_ADC_Stop(&hadc1);

    // Convert to voltage (assuming 12-bit ADC, 0-3.3V)
    float voltage = ((float)raw / 4095.0f) * 3.3f;
    return voltage;
}

// --- Main ---
int main(void){
    HAL_Init();
    // Init GPIOs, ADC, TIMs, EXTI here

    while(1){
        // Optional diagnostics
    }
}

Is our understanding is correct ?

Let me clarify the setup and why it works this way:

Stock ECU remains mostly ignorant of actual fuel injection:

Stock 6 injectors are no longer used for fuelling the engine.

MST32 takes over all fuel injection directly into the engine.

Stock ECU still sees the injectors as a load through dummy loads such as resistors inductors capacitors so it doesn’t throw errors or enter limp mode.

O₂ sensor in stock ECU:

Yes it just reports oxygen levels to the stock ECU.

The stock ECU may still try closed-loop adjustments on ignition timing or if it uses AFM-based fuelling it can continue pretending it controls fuel.

Since MST32 handles actual fuel delivery the O₂ sensor feedback in stock ECU is essentially ignored for fuelling then fuel mixture is now fully controlled by MST32 using TPS or optionally O₂ sensor if we want real closed-loop.

Ignition and airflow:
Stock ECU can still manage ignition coils and spark timing using its normal sensors.

AFM air flow meter if present continues to give airflow info to the stock ECU so it runs normally without detecting a problem.

Benefits of this approach:
We don’t need to hack the stock ECU to disable closed-loop fuelling.

MST32 can safely control sequential injection while the stock ECU thinks everything is normal.

Fuel mixture accuracy is based entirely on our MST32 TPS map and optional O₂ sensor if we ever want closed-loop on MST32.

In short:

Our MST32 handles fuel.

Stock ECU handles ignition and thinks it still controls fuel. Oxygen sensor just continues reporting to stock ECU it doesn’t interfere with our MST32 fuel control.

I don't think it will work.
The maximum ADC input of the STM32 boards is 3.3v, but your tps_volts table goes up to 5v.
Apparently, the code is assembled from separate pieces of similar code for different controllers. It's probably an AI product—and like most AI products, it's just a template, unsuitable for real use. You'll have to refine it to make it work.

Addition:
After taking a closer look, I think the code is completely ineffective. You've only tied injection for bank A to RPM, while banks B and C are injected with a fixed 53ms delay, which has nothing to do with the correct timing. You're counting the number of teeth on the crank, but you don't use it anywhere in the code.
So my initial conclusion was correct—the code is plucked from examples found online, without the understanding of how it's supposed to work. Nothing good will come of such a half-baked approach.

1 Like
  1. Use a resistor voltage divider to scale the TPS 0–5 V to ~0–3.0 V leave margin below 3.3 V.

Add an input capacitor 10–100 nF at the ADC pin for noise filtering.

Add a clamping and protection diode Schottky from ADC pin to 3.3 V rail or a TVS if we want extra safety against transients.

Optionally buffer the divider output with a rail-to-rail op-amp if the ADC sampling capacitor disturbs the TPS or we need low source impedance.

Example divider that’s safe values we can use.

Aim for Vadc = Vin × Rbottom and Rtop + Rbottom.

We want Vadc_max ≈ 3.0 V when Vin = 5.0 V gives margin under 3.3 V.

A good simple choice:

R_top = 12 kΩ

R_bottom = 18 kΩ

Check: 18 / 12+18 = 18/30 = 0.6 → 5.0 V × 0.6 = 3.0 V

Add a 10 nF capacitor from ADC pin to ground to form a simple low-pass helps with noise and stable ADC sampling.

How to compute TPS voltage in software ?

If ADC is 12-bit ADC_max = 4095 and Vref = 3.3 V and we used the divider above:

Read raw = HAL_ADC_GetValue(&hadc1);

v_adc = raw / 4095.0f * 3.3f; // voltage at ADC pin

  1. v_tps = v_adc * (R_top + R_bottom) / R_bottom;
    With Rtop=12k, Rbot=18k: multiply v_adc by 30/18 = 1.6666667
    So v_tps = v_adc * 1.6667

Example: if raw corresponds to v_adc = 3.0 V v_tps = 5.0 V.

Important: calibrate this in software with an accurate meter resistor tolerances and Vref differences mean it’s worth measuring and adding a correction factor.

Why not a DC-DC converter?

A DC-DC converter changes power rails voltage supply. TPS is a low-level analog signal we should scale it or buffer it not convert power.

If by D-D we meant a voltage divider D–D then yes that’s appropriate. If we really meant a DC-DC power converter don’t use that for the TPS input.

Extra protection and robustness tips.

Use 1% tolerance resistors if we need good accuracy.

Add a series resistor 100–1kΩ between divider and ADC pin to limit source impedance to the ADC sampling capacitor. If we do this increase the capacitor to keep RC reasonable.

Use a Schottky clamp diode or a small TVS on the ADC pin for transient protection.

If TPS is noisy consider a small op-amp buffer rail-to-rail after the divider and before the ADC.

Ensure common ground between TPS STM32 and vehicle battery.

You are doing the same as in your first two threads - instead of answering the questions you are printing AI-generated garbage.
You spent almost a year generating this 200-line code. And it still doesn't work. During that time, you could have learned to write code yourself. And yet, you still don't understand a thing about the program.
You even didn't understand what I wrote to you. Using a voltage divider doesn't helps you with your code errors.

1 Like

We did not add start up mode when the engine is cold.

Startup first 2 minutes after VIN/boot: batch mode all 3 banks fire together once per crank revolution with a fixed pulse width = 5.0 ms.

After 2 minutes 120000 ms: switch automatically to sequential mode injector bank A → injector bank B → injector bank C spaced 240° degrees = 53.333 ms = 38 teeth at 750 RPM.

Uses the crank tooth ISR interrupt service routing 60–2 wheel → 58 teeth per revolution for coarse timing integer tooth counts.

Uses a high-resolution µs one-shot timer to wait the fractional remainder the fractional part of 38.6667 teeth before firing integer-tooth + residual-µs scheduling.

Uses the CID cam and cylinder ID pulse as the 720° degrees reference t = 0 for sequential scheduling after startup.

TPS ADC → full lookup + linear interpolation 1.5–6.4 ms to get pulse width during sequential mode.

Measures CID cylinder identification sensor period to estimate rev_time_us used to compute residual microseconds.

Handles missing-tooth gap detection to re-sync tooth counting.

Notes before we flash:

We must wire the pins exactly or change #defines:
Injectors bank outputs: PA0 Bank A PA1 Bank B PA2 Bank C

TPS ADC: PA3 ADC_CHANNEL_3

CID cam/cylinder ID: PB3 EXTI

CRANK tooth input: PB4 EXTI

Configure CubeMX to provide:

EXTI on PB3 CID and PB4 CRK tooth

ADC1 channel 3 PA3 with HAL_ADC

TIMs:
htim_us — free-running 1 MHz timer microsecond counter no interrupt

htim_res — one-shot timer in microsecond mode used for fractional waits residual_us

htim_pw — one-shot timer used to turn injectors off after pulse width

Set IRQ interrupt request priorities: CRANK tooth ISR high: CID next: TIM interrupts medium.

The code is interrupt-driven: keep heavy calculations out of ISRs except what’s required.

Code below.

/* injector_controller.c
   STM32 HAL-based sequential / batch injector controller with TPS divider handling.
   Integrated CID authoritative phase lock: Bank A first after CID, then B, C, ...
   -------------------------------------------------------------------------------
   Requirements (CubeMX):
    - htim_us  : free-running @1 MHz (micros())
    - htim_res : basic TIM for µs one-shot residual scheduling (interrupt)
    - htim_pw  : basic TIM for µs one-shot PW off scheduling (interrupt)
    - hadc1    : ADC1 with TPS channel configured
    - EXTI     : CRK_PIN and CID_PIN EXTI interrupts enabled
   -------------------------------------------------------------------------------
*/

#include "stm32f1xx_hal.h"
#include <stdint.h>
#include <stdbool.h>
#include <math.h>

// ----------------- User-configurable pin / peripheral mapping -----------------
#define INJ_A_PORT   GPIOA
#define INJ_A_PIN    GPIO_PIN_0
#define INJ_B_PORT   GPIOA
#define INJ_B_PIN    GPIO_PIN_1
#define INJ_C_PORT   GPIOA
#define INJ_C_PIN    GPIO_PIN_2

#define TPS_ADC_CHANNEL  ADC_CHANNEL_3  // PA3 (through divider)
#define CID_PIN          GPIO_PIN_3     // PB3 (cam / cylinder ID)
#define CID_PORT         GPIOB
#define CRK_PIN          GPIO_PIN_4     // PB4 (crank tooth)
#define CRK_PORT         GPIOB

// TIM / ADC handles (configure in CubeMX and define in main.c)
extern TIM_HandleTypeDef htim_us;   // free-running @1 MHz -> micros()
extern TIM_HandleTypeDef htim_res;  // residual wait one-shot (µs)
extern TIM_HandleTypeDef htim_pw;   // injector pulse width off timer (µs)
extern ADC_HandleTypeDef hadc1;     // ADC1 configured for TPS channel

// ----------------- Divider / ADC constants -----------------
#define ADC_MAX         4095.0f
#define VREF            3.3f
// Divider: Rtop=12k, Rbot=18k -> ADC sees 0..3.0V for TPS 0..5.0V
#define DIV_RATIO       ((12.0f + 18.0f) / 18.0f) // 30/18 = 1.6666667

// ----------------- Other Constants -----------------
#define TEETH_PER_REV       58
#define TEETH_PER_720       (TEETH_PER_REV * 2)  // 116
#define DEG_PER_TOOTH       (360.0f / (float)TEETH_PER_REV) // ~6.2068966
#define STARTUP_TIME_MS     120000U   // 2 minutes
#define STARTUP_PW_US       5000U     // 5 ms = 5000 us during startup/batch mode
#define MIN_PW_US           200U      // safety minimum
#define MAX_PW_US           10000U    // safety max
#define MIN_RESIDUAL_US     10U       // ignore micro waits below this

// CID sync loss timeout (ms) — if no CID seen in this time, clear cid_synced
#define CID_LOSS_TIMEOUT_MS 2000U

// Bank angles (deg) relative to CID / 0 point
static const float bank_angles_deg[3] = { 0.0f, 240.0f, 480.0f };

// TPS map (voltage in V -> pulse width in ms) 100 points
#define TPS_BINS_N 100
static const float tps_volts[TPS_BINS_N] = {
  0.050,0.100,0.150,0.200,0.250,0.300,0.350,0.400,0.450,0.500,
  0.550,0.600,0.650,0.700,0.750,0.800,0.850,0.900,0.950,1.000,
  1.050,1.100,1.150,1.200,1.250,1.300,1.350,1.400,1.450,1.500,
  1.550,1.600,1.650,1.700,1.750,1.800,1.850,1.900,1.950,2.000,
  2.050,2.100,2.150,2.200,2.250,2.300,2.350,2.400,2.450,2.500,
  2.550,2.600,2.650,2.700,2.750,2.800,2.850,2.900,2.950,3.000,
  3.050,3.100,3.150,3.200,3.250,3.300,3.350,3.400,3.450,3.500,
  3.550,3.600,3.650,3.700,3.750,3.800,3.850,3.900,3.950,4.000,
  4.050,4.100,4.150,4.200,4.250,4.300,4.350,4.400,4.450,4.500,
  4.550,4.600,4.650,4.700,4.750,4.800,4.850,4.900,4.950
};

static const float tps_pw_ms[TPS_BINS_N] = {
  1.5,1.5,1.6,1.6,1.7,1.7,1.8,1.8,1.9,1.9,
  2.0,2.0,2.1,2.1,2.2,2.2,2.3,2.3,2.4,2.4,
  2.5,2.5,2.6,2.6,2.7,2.7,2.8,2.8,2.9,2.9,
  3.0,3.0,3.1,3.1,3.2,3.2,3.3,3.3,3.4,3.4,
  3.5,3.5,3.6,3.6,3.7,3.7,3.8,3.8,3.9,3.9,
  4.0,4.0,4.1,4.1,4.2,4.2,4.3,4.3,4.4,4.4,
  4.5,4.5,4.6,4.6,4.7,4.7,4.8,4.8,4.9,4.9,
  5.0,5.0,5.1,5.1,5.2,5.2,5.3,5.3,5.4,5.4,
  5.5,5.5,5.6,5.6,5.7,5.7,5.8,5.8,5.9,5.9,
  6.0,6.0,6.1,6.1,6.2,6.2,6.3,6.3,6.4,6.4
};

// ----------------- Runtime / state -----------------
volatile uint32_t tooth_in_rev = 0;       // 0..57 counted between missing-gap resync
volatile int toothAfterCID = 0;           // 0..115 (counts teeth since last CID)
volatile bool cid_seen = false;
volatile uint32_t last_cid_us = 0;
volatile uint32_t rev_time_us = 80000;    // measured 1 rev time (360°) in microseconds; initial guess
volatile float time_per_degree_us = 222.222f; // rev_time_us/360

int bank_integer_tooth[3];     // integer tooth indices after CID (0..115)
uint32_t bank_residual_us[3];  // fractional wait in microseconds

uint32_t bank_pulse_us[3];     // during sequential mode from TPS map

volatile bool sequential_mode = false;

// scheduling helpers
volatile int scheduled_bank_for_residual = -1; // which bank is waiting in residual timer
volatile int scheduled_bank_for_pw_off = -1;   // which bank to turn OFF on pw timer
volatile bool startup_batch_active = false;    // true when we've fired all injectors in startup and waiting to turn all off

// ----------------- Extra sequencing state -----------------
volatile bool cid_synced = false;           // set true once CID reference has been used to align banks
volatile int expected_next_bank = 0;        // 0=A,1=B,2=C : which bank is allowed next when cid_synced==true

// CID loss watchdog
static uint32_t last_cid_tick_ms = 0;

// ----------------- Helpers -----------------
static inline uint32_t micros(void) {
    return __HAL_TIM_GET_COUNTER(&htim_us);
}

// linear interpolate TPS table to get pulse width in microseconds
static uint32_t get_pw_from_tps_voltage(float v) {
    if (v <= tps_volts[0]) return (uint32_t)(tps_pw_ms[0]*1000.0f + 0.5f);
    if (v >= tps_volts[TPS_BINS_N-1]) return (uint32_t)(tps_pw_ms[TPS_BINS_N-1]*1000.0f + 0.5f);
    int i;
    for (i=0;i<TPS_BINS_N-1;i++){
        if (v <= tps_volts[i+1]) break;
    }
    float t = (v - tps_volts[i]) / (tps_volts[i+1] - tps_volts[i]);
    float pw_ms = tps_pw_ms[i] + t * (tps_pw_ms[i+1] - tps_pw_ms[i]);
    uint32_t pw_us = (uint32_t)(pw_ms * 1000.0f + 0.5f);
    if (pw_us < MIN_PW_US) pw_us = MIN_PW_US;
    if (pw_us > MAX_PW_US) pw_us = MAX_PW_US;
    return pw_us;
}

// ----------------- TPS ADC read (with divider reconstruction) -----------------
static float read_tps_voltage(void) {
    ADC_ChannelConfTypeDef sConfig = {0};
    sConfig.Channel = TPS_ADC_CHANNEL;
    sConfig.Rank = ADC_REGULAR_RANK_1;
    sConfig.SamplingTime = ADC_SAMPLETIME_28CYCLES_5;
    HAL_ADC_ConfigChannel(&hadc1, &sConfig);

    HAL_ADC_Start(&hadc1);
    if (HAL_ADC_PollForConversion(&hadc1, 10) == HAL_OK) {
        uint32_t raw = HAL_ADC_GetValue(&hadc1);
        HAL_ADC_Stop(&hadc1);
        float v_adc = ((float)raw / ADC_MAX) * VREF; // voltage at ADC pin (after divider)
        float v_tps = v_adc * DIV_RATIO;             // reconstruct TPS (0..5V)
        return v_tps;
    }
    // fallback low value
    return tps_volts[0];
}

// ----------------- Injector I/O -----------------
static inline void injector_on(int bank) {
    if (bank==0) HAL_GPIO_WritePin(INJ_A_PORT, INJ_A_PIN, GPIO_PIN_SET);
    else if (bank==1) HAL_GPIO_WritePin(INJ_B_PORT, INJ_B_PIN, GPIO_PIN_SET);
    else HAL_GPIO_WritePin(INJ_C_PORT, INJ_C_PIN, GPIO_PIN_SET);
}
static inline void injector_off(int bank) {
    if (bank==0) HAL_GPIO_WritePin(INJ_A_PORT, INJ_A_PIN, GPIO_PIN_RESET);
    else if (bank==1) HAL_GPIO_WritePin(INJ_B_PORT, INJ_B_PIN, GPIO_PIN_RESET);
    else HAL_GPIO_WritePin(INJ_C_PORT, INJ_C_PIN, GPIO_PIN_RESET);
}
static inline void injector_all_off(void) {
    HAL_GPIO_WritePin(INJ_A_PORT, INJ_A_PIN, GPIO_PIN_RESET);
    HAL_GPIO_WritePin(INJ_B_PORT, INJ_B_PIN, GPIO_PIN_RESET);
    HAL_GPIO_WritePin(INJ_C_PORT, INJ_C_PIN, GPIO_PIN_RESET);
}

// ----------------- Scheduling math -----------------
static void compute_bank_targets_from_CID(void) {
    float deg_per_tooth = DEG_PER_TOOTH;
    for (int b=0;b<3;b++){
        float angle = bank_angles_deg[b]; // 0/240/480
        if (angle < 0.0f) while(angle<0.0f) angle += 720.0f;
        if (angle >= 720.0f) angle = fmodf(angle, 720.0f);
        float tooth_index = angle / deg_per_tooth; // fractional teeth e.g. 38.6667
        int integer_tooth = (int)floorf(tooth_index + 1e-6f);
        float frac = tooth_index - (float)integer_tooth;
        float residual_deg = frac * deg_per_tooth;
        uint32_t residual_us = (uint32_t)roundf(residual_deg * time_per_degree_us);
        bank_integer_tooth[b] = integer_tooth;
        bank_residual_us[b] = residual_us;
    }
}

// ----------------- Timers / callbacks -----------------
static void start_residual_timer_us(uint32_t us, int bank) {
    // guard: avoid double-scheduling same bank
    if (scheduled_bank_for_residual == bank) return;

    if (us < MIN_RESIDUAL_US) {
        // immediate ON
        scheduled_bank_for_residual = -1;
        injector_on(bank);
        // Advance expected_next_bank here only when injector actually turns ON in residual callback.
        // But in this immediate-case we already turned it ON so advance now:
        if (cid_synced) {
            expected_next_bank = (expected_next_bank + 1) % 3;
        }
        scheduled_bank_for_pw_off = bank;
        __HAL_TIM_SET_COUNTER(&htim_pw, 0);
        __HAL_TIM_SET_AUTORELOAD(&htim_pw, bank_pulse_us[bank] - 1);
        HAL_TIM_Base_Start_IT(&htim_pw);
        return;
    }
    scheduled_bank_for_residual = bank;
    __HAL_TIM_SET_COUNTER(&htim_res, 0);
    __HAL_TIM_SET_AUTORELOAD(&htim_res, us - 1);
    HAL_TIM_Base_Start_IT(&htim_res);
}

void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) {
    if (htim == &htim_res) {
        HAL_TIM_Base_Stop_IT(&htim_res);
        int b = scheduled_bank_for_residual;
        scheduled_bank_for_residual = -1;
        if (b >= 0 && b < 3) {
            injector_on(b);
            // Immediately advance expected_next_bank so next valid bank is allowed
            if (cid_synced) {
                expected_next_bank = (expected_next_bank + 1) % 3;
            }
            scheduled_bank_for_pw_off = b;
            __HAL_TIM_SET_COUNTER(&htim_pw, 0);
            __HAL_TIM_SET_AUTORELOAD(&htim_pw, bank_pulse_us[b] - 1);
            HAL_TIM_Base_Start_IT(&htim_pw);
        }
    } else if (htim == &htim_pw) {
        HAL_TIM_Base_Stop_IT(&htim_pw);
        // Special-case: if startup_batch_active==true then turn ALL injectors off
        if (startup_batch_active) {
            injector_all_off();
            startup_batch_active = false;
            scheduled_bank_for_pw_off = -1;
            return;
        }
        int b = scheduled_bank_for_pw_off;
        scheduled_bank_for_pw_off = -1;
        if (b >= 0 && b < 3) injector_off(b);
    }
}

// ----------------- EXTI ISRs (Crank tooth + CID) -----------------
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) {
    uint32_t now_us = micros();

    if (GPIO_Pin == CRK_PIN) {
        static uint32_t last_tooth_us = 0;
        uint32_t dt = (last_tooth_us==0) ? 0 : (now_us - last_tooth_us);
        last_tooth_us = now_us;

        tooth_in_rev++;
        if (tooth_in_rev >= TEETH_PER_REV) tooth_in_rev = 0;

        float avg_tooth_us = (rev_time_us / (float)TEETH_PER_REV);
        if (dt > (uint32_t)(avg_tooth_us * 2.2f) && dt > 2000u) {
            // missing-gap detected: reset tooth count in rev
            tooth_in_rev = 0;
        }

        if (cid_seen) {
            toothAfterCID++;
            if (toothAfterCID >= TEETH_PER_720) toothAfterCID = 0;

            // Only schedule the bank that matches expected_next_bank when CID is synced.
            // If CID not yet synced, allow scheduling of any bank (legacy behavior).
            for (int b=0;b<3;b++) {
                if (toothAfterCID == bank_integer_tooth[b]) {
                    if (!cid_synced || (b == expected_next_bank)) {
                        start_residual_timer_us(bank_residual_us[b], b);
                    }
                }
            }
        }
        return;
    }

    if (GPIO_Pin == CID_PIN) {
        uint32_t now = micros();
        if (last_cid_us != 0) {
            uint32_t period = now - last_cid_us; // µs between CID pulses (720°)
            uint32_t new_rev_time_us = period / 2; // 360°
            // low-pass / smoothing
            rev_time_us = (uint32_t)(0.85f * (float)rev_time_us + 0.15f * (float)new_rev_time_us);
            time_per_degree_us = (float)rev_time_us / 360.0f;
        }
        last_cid_us = now;

        cid_seen = true;
        toothAfterCID = 0;

        // Compute bank tooth targets/residuals based on this CID reference
        compute_bank_targets_from_CID();

        // Mark as synced and force Bank A to be the next bank to fire.
        cid_synced = true;
        expected_next_bank = 0; // Bank A is first after CID
        last_cid_tick_ms = HAL_GetTick();

        if (!sequential_mode) {
            // startup batch mode: fire all banks for STARTUP_PW_US once
            injector_on(0); injector_on(1); injector_on(2);
            startup_batch_active = true;
            // start single PW timer to turn all off
            __HAL_TIM_SET_COUNTER(&htim_pw, 0);
            __HAL_TIM_SET_AUTORELOAD(&htim_pw, STARTUP_PW_US - 1);
            HAL_TIM_Base_Start_IT(&htim_pw);
        } else {
            // sequential mode: compute pulse widths from TPS
            float tps_v = read_tps_voltage();             // returns 0..~5V
            uint32_t pw = get_pw_from_tps_voltage(tps_v);
            bank_pulse_us[0] = bank_pulse_us[1] = bank_pulse_us[2] = pw;

            // schedule Bank A as the first bank after CID using its residual offset
            start_residual_timer_us(bank_residual_us[0], 0);
        }
    }
}

// ----------------- Mode switch (startup timer) -----------------
static void check_startup_timeout(void) {
    static uint32_t boot_ms = 0;
    if (boot_ms == 0) boot_ms = HAL_GetTick();
    if (!sequential_mode && (HAL_GetTick() - boot_ms >= STARTUP_TIME_MS)) {
        sequential_mode = true;
        // Will switch to sequential on next CID sync
    }

    // CID loss watchdog: clear cid_synced if no CID seen recently
    if (cid_synced) {
        uint32_t now_ms = HAL_GetTick();
        if ((now_ms - last_cid_tick_ms) > CID_LOSS_TIMEOUT_MS) {
            cid_synced = false;
            // optional: reset expected_next_bank to 0 so first seen bank will be allowed
            expected_next_bank = 0;
        }
    }
}

// ----------------- Initialization helper -----------------
void injector_controller_init(void) {
    injector_all_off();
    cid_seen = false;
    cid_synced = false;
    sequential_mode = false;
    startup_batch_active = false;
    scheduled_bank_for_residual = -1;
    scheduled_bank_for_pw_off = -1;
    expected_next_bank = 0;
    last_cid_tick_ms = HAL_GetTick();

    // start microsecond timer (configure htim_us @1MHz in CubeMX)
    HAL_TIM_Base_Start(&htim_us);
    compute_bank_targets_from_CID();
}

// ----------------- Main loop (call from main.c) -----------------
int main(void) {
    HAL_Init();
    SystemClock_Config();

    MX_GPIO_Init();
    MX_ADC1_Init();
    MX_TIM_US_Init();   // htim_us @1MHz
    MX_TIM_RES_Init();  // htim_res basic TIM for µs one-shot
    MX_TIM_PW_Init();   // htim_pw basic TIM for µs one-shot
    MX_EXTI_Init();     // Configure PB3 and PB4 as EXTI

    injector_controller_init();

    while (1) {
        check_startup_timeout();
        HAL_Delay(10);
    }
}

You are troll who should be banned forever.
Bye.

With two injector banks works I did tested with oscilloscope with 2 injectors banks working sequentially every 360 degrees and pulse width works depending on TPS voltage.

3 injector banks is better then 2.

Let’s fix step by step. The new updated code written without D-D