Yeah, nah, aye

Main categories:

Reverse Engineering lead acid charger firmware

Work-in-progress notes.

Debug Header

Beware: the control board has a 6-bin debug header J3 but this is not the standard ISP pinout. From the top side, it looks like:

     +-----+
MISO |1 | 2| SCK
     +--/  |
MOSI |3   4| nRESET
     |     |
 VCC |5   6| GND
     +-----+

The control board is fine with VCC as 5V. This is what it gets from the main board. With it removed from the main charger, you can feasibly power it up from your ISP adapter for pulling firmware out.

Fuses

The fuses read out along with the entire program flash and EEPROM. There seems to be no attempt to lock the firmware down from readout, which is convenient.

fuses_lo = 0x24
fuses_hi = 0x91
lock_byte = 0xff

These translate as:

RSTDISBL 1: PC6 is still nRST
WDTON 0: WDT always on
SPIEN 0: SPI programming enabled
EESAVE 0: EEPROM retainted during chip erase
BOOTSZ 00: Boot flash size 1024 bytes
BOOTRST 1: Interrupt vector lives in application

BODLEVEL 0
BODEN 0
Brown-out detection enabled at 4 V

CKOPT 1
CKSEL 0100 (4) 8 MHz
SUT1 10 (2)
8 MHz internal RC clock.
Start-up time 6 clocks plus 65 ms

Microcontroller pinout

FIXME get markdown tables rendering to HTML :)

|Pin| Special | Port | Connection                |
|--:| ------- | ---- | ------------------------- |
| 1 | INT0    | PD3  | Push button               |
| 2 | XCK     | PD4  | LED10: Analysis           |
| 3 | GND     |      | GND                       |
| 4 | VCC     |      | +5V                       |
| 5 | GND     |      | GND                       |
| 6 | VCC     |      | +5V                       |
| 7 | XTAL1   | PB6  | Main board high-side FET  |
| 8 | XTAL2   | PB7  | LED 11: Boost             |
| 9 | T1      | PD5  | LED 3: Gel                |
|10 | AIN0    | PD6  | LED 4: Pb Acid            |
|11 | AIN1    | PD7  | LED 5: Calcium            |
|12 | ICP1    | PB0  | LED 12: Maintain          |
|13 | OC1A    | PB1  | Conn pin 7 via RC filters |
|14 | SS      | PB2  | LED 2: Fault              |
|15 | MOSI    | PB3  | J3 pin 3                  |
|16 | MISO    | PB4  | J3 pin 1                  |
|17 | SCK     | PB5  | J3 pin 2                  |
|18 | AVCC    |      | +5V                       |
|19 | ADC6    |      | LM324 out3 via 10k        |
|20 | AREF    |      |                           |
|21 | GND     |      | GND                       |
|22 | ADC7    |      | Thermistor                |
|23 | ADC0    | PC0  | 10 to 15% battery voltage |
|24 | ADC1    | PC1  | Q -> 3k OC -> conn pin 6  |
|25 | ADC2    | PC2  | Q -> 6k8 OC -> conn pin 6 |
|26 | ADC3    | PC3  | LED 9: Absorb             |
|27 | ADC4    | PC4  | LED 6: Desulfation        |
|28 | ADC5    | PC5  | LED 7: Soft start         |
|29 | nRESET  | PC6  | J3 pin 4                  |
|30 | RXD     | PD0  | LED 8: Bulk               |
|31 | TXD     | PD1  | Buzzer NPN                |
|32 | INT0    | PD2  | Reverse voltage sensing   |

Main connector pinout

This connects the control board to the charger board. Pin 1 is nearest the buzzer on the control board. I/O marked from the perspective of the charger board.

|Pin| Dir. | Description               |
|==:| ---- | ------------------------- |
| 1 | Out  | out3 from mainboard LM324 |
| 2 |      | 5 VDC / VCC               |
| 3 |      | 0 V / GND                 |
| 4 | In   | Charge enable             |
| 5 | Out  | Battery positive          |
| 6 | In   | Some stepped ref. voltage |
| 7 | In   | Some smooth ref. voltage  |

Globals

These are stored in data space and potentially modified during program execution.

Interrupts

The only interrupts in the vector are:

Let's elaborate on these in pseudocode.

IRQ0 Handler (low level, battery connected in reverse)

This is a low-level-triggered interrupt. The core of the ISR is to buzz the buzzer and flash the LED for the user's selected chemistry. Somewhat interestingly the fault LED isn't lit in this routine. Presumably this LED was left for indicating a faulty battery rather than a faulty connection.

Also interesting is the cheap debouncing performed throughout the routing, really making sure the battery is reverse connected. It makes the code quite hard to express cleanly in C-like pseudo code.

ISR(INT0_vect) {
    if (battery reverse connected) {
        delay_ms(5);
        if (battery reverse connected) {
            disconnect_charger();
            led_off_desulfation;
            led_off_soft_start;
            led_off_bulk;
            led_off_absorb;
            led_off_analysis;
            led_off_boost;
            led_off_maintain;
            do {
                do {
                    led_on_current_chemistry()
                    buzzer_on
                    delay_ms(500)
                    led_off_all_chemistry
                    buzzer_off
                    delay_ms(500)
                } while battery reverse connected
                delay_ms(10)
            } while battery reverse connected
            led_light_current_chemistry()
        }
    }
}

IRQ1 Handler (falling edge, button press)

Edge triggered, this ISR is called when the user presses the single button on the front of the charger to change the chemistry of the battery. Each press advances the state machine one state, with wrap-around at the end:

Again there's some cheap debouncing done by sleeping around 10 milliseconds and re-checking the level of the button input to make sure it's really pressed.

What's really interesting/odd about this ISR is that it introduces a completely new global variable which stores the current chemistry in a completely new variable at 0x90 (which I've named chemistry_prime), with completely different meaning to the one at 0x91. This seems odd, since the rest of the program uses 0x91 happily.

I can't think of a reason to have a completely separate variable here, not especially with the meaning it has. Being 1-based, the variable gets 1 subtracted from it everywhere it's used, including before the switch-case jump table. The more I read this code the more it looks like plain old unstructured spaghetti produced by a human rather than some esoteric compiler.

ISR(INT1_vect) {
    delay_ms(10);
    if (button still pressed) {
        switch (chemistry_prime - 1) {
        case 0:
            // was lead acid, new chemistry calcium
            chemistry_prime = 3;
            chemistry = 2;
            led_off_gel;
            led_off_lead_acid;
            led_on_calcium;
            some_globalb4, some_globalb5 = 0x03, 0xb4;
            some_globalb6, some_globalb7 = 0x03, 0x47;
            some_globalc8, some_globalc9 = 0x03, 0xd1;
            some_globalc2, some_globalc3 = 0x00, 0x00;
            break
        case 1:
            // was gel, new chemistry lead acid
            chemistry_prime = 1;
            chemistry = 1;
            led_off_gel;
            led_on_lead_acid;
            led_off_calcium;
            some_globalb4, some_globalb5 = 0x03, 0x86;
            some_globalb6, some_globalb7 = 0x03, 0x47;
            some_globalc8, some_globalc9 = 0x03, 0x9d;
            some_globalc2, some_globalc3 = 0x00, 0x00;
            break;
        case 2:
            // was calcium, new chemistry gel
            chemistry_prime = 2;
            chemistry = 0;
            led_on_gel;
            led_off_lead_acid;
            led_off_calcium;
            some_globalb4, some_globalb5 = 0x03, 0x67;
            some_globalb6, some_globalb7 = 0x03, 0x29;
            some_globalc8, some_globalc9 = 0x03, 0x7f;
            some_globalc2, some_globalc3 = 0x00, 0x00;
            break;
    }
    delay_ms(300);
    watchdog_reset;
    interrupt_enable;
}

_start

Or at least that's what I'll call it.

This routine appears written in assembler and is fairly flat looking.

It sets up various peripherals and global settings such as stack pointer, ports, interrupts, core power state, timers.

SP := 0x45f               ; top of SRAM
OSCCAL := program[0x1c00] ; 154 decimal. This should put RC osc. at ~6 MHz
MCUCR := 0x08             ; Awake, idle, INT1 on falling edge, INT0 on low level
DDRB := 0x17              ; 0001 0111: PB4, PB2, PB1, PB0 output
; extra hting in here     ; 
DDRC := 0xfe              ; 1111 1110: PC7, PC6, PC5, PC4, PC3, PC2, PC1 output
DDRD := 0xf3              ; 1111 0011: PD7, PD6, PD5, PD4, PD1, PD0 output
data[0x90] := 1
data[0x91] := 1
data[0xcb] := 1
data[0xcc] := 0x60        ; 96
data[0xcd] := 1
data[0xce] := 0x29        ; 41
data[0xcf] := 1
data[0xd0] := 0x10        ; 16
data[0xd1] := 0
data[0xd2] := 0xf0        ; 240
TCNT1 := 0x0

It then runs through a quick LED strobe which takes about 1 second. It lights the battery chemistry and fault LEDs for half a second, then it switches to lighting only the charge state LEDs. Doesn't seem to be a reason for this other than perhaps a sanity test a user can make that all LEDs are working. Perhaps it also gives the charger board it's controlling some time to become stable.

PORTC4 := 1               ; off: Desulfation
PORTC5 := 1               ; off: Soft start
PORTD0 := 1               ; off: Bulk
PORTC3 := 1               ; off: Absorb
PORTD4 := 1               ; off: Analysis
PORTB7 := 1               ; off: Boost
PORTB0 := 1               ; off: Maintain
PORTD5 := 0               ; on: Gel
PORTD6 := 0               ; on: Pb acid
PORTD7 := 0               ; on: Calcium
PORTB2 := 0               ; on: Fault
delay_ticks(0xfa)         ; about 500 ms
PORTC4 := 0               ; on: Desulfation
PORTC5 := 0               ; on: Soft start
PORTD0 := 0               ; on: Bulk
PORTC3 := 0               ; on: Absorb
PORTD4 := 0               ; on: Analysis
PORTB7 := 0               ; on: Boost
PORTB0 := 0               ; on: Maintain
PORTD5 := 1               ; off: Gel
PORTD6 := 1               ; off: Pb acid
PORTD7 := 1               ; off: Calcium
PORTB2 := 1               ; off: Fault
delay_ticks(0xfa)         ; about 500 ms

More memory initialisation stuff happens after this

data[0xac] := 0
data[0xab] := 0
data[0xb4] := 3          ; FIXME these look like words
data[0xb5] := 0x86       ; Preload 0xb4–0xb7 with Pb Acid values
data[0xb6] := 3
data[0xb7] := 0x47
data[0xc8] := 3          ; Preload 0xc8–0xc9 with Pb Acid values
data[0xc9] := 0x9d
data[0x91] := 1          ; Preload chemistry with Pb Acid
data[0xb0] := 1
data[0xb1] := 0x91

Followed by timer 1 setup for PWM.

OCR1A := 0x0191          ; out compare A for timer1 is 0x191
ICR1 := 0x07d0           ; preloading input compare why?
TCCR1B := 0x19           ; WGM13:WGM12 is 11
                         ; No prescaler.
TCCR1A := 0x82           ; WGM11:WGM10 is 10 => fast PWM
                         ; Top ICR1, TOV1 set at top, OCR1x updated at bottom
                         ; OC1A cleared on compare match, OC1B disconnected
GICR := 0xc1             ; Begin IVSEL set
GICR := 0xc1             ; Enable INT1, INT0
                         ; Also invalidates any IVSEL set, since IVCE still 1. FIXME check cycles with nops below
nop
nop

LEDs are changed again

PORTB2 := 1              ; off: Fault
PORTD6 := 0              ; on: Pb Acid

Interrupts enabled and watchdog patted:

sei
wdr

uint16_t adc_read(uint8_t)

This function appears to read the ADC based on a ADMUX value provided as a direct argument. It leaves the low and high bytes of the read value stored to adc_val_hi:adc_val_lo, as well as r27:r26.

It's kind of interesting they chose to insert a fixed delay rather than polling on the ADSC bit in the ADCSRA register. Given the clock division involved (only /8), the conversion will happen well within the fixed delay in the code, but it's still a bit sloppy. In addition the ADSC bit in ADCSRA is cleared after the conversion value is read which is an interesting quirk adding to the evidence that this code was produced by hand-crafted assembly. The datasheet says that this has no effect.

uint16_t adc_read(uint8_t admux) {
    ADMUX = admux;
    ADCSRA = 0xc3; // Enable ADC, clock /8 start conversion
    delay_ms(5);
    adc_val_lo = ADCL;
    adc_val_hi = ADCH;
    adc_disable;
    adc_int_disable;
    adc_clear_conversion_start;
    return ADCH:ADCL;
}

void delay_ticks(uint8_t)

Not a very interesting function. Weirdest thing is some of the assembler that is essentially a no-op (maybe hangover from dev/debug?), e.g.:

in r16, TIMSK
ori r16, 0
out TIMSK, r16

The datasheet doesn't say anything is triggered when writing this register in this way.

#define delay_ms(x) delay_ticks(x/2)
void delay_ticks(uint8_t tick_count) {
    TCCR1 = 3; // prescale /64 => overflow every 2–3 ms
    TCNT0 = 5; // preload non-zero to account for fixed overhead
    do {
        do {
            watchdog_reset
        } until timer overflow
        tick_count = tick_count - 1
    } while tick_count != 0
}