Actron Aircon / ESP32 Controller Help

I got my M5 devices from AliExpress in the end. Good prices and cheap delivery.
Digikey wanted $24 for delivery to WA.

I paid $33.3 inc delivery from AliExpress - should arrive sometimes mid to end of next week.

You could actually use any ESP32 or even ESP8266 for this - both available on Amazon (with next day delivery) or on ebay. I only used M5 because I had available and it’s an easy form factor for testing.

I did the same. I got the m5 atom and the dac on the 25th and it got delivered yesterday. Not too bad.

Can anyone post a pic of their test bed setup for reference?

Got mine yesterday too :slight_smile:

I have managed to simplify my config a bit - if you are using an ESP32 with DAC (like the atom lite) you don’t need the external DAC.
Schematic:


Veroboard:

Photo1

Installed:

Below is my ESPhome yaml configuration:

#BCK - this version has been adopted to use built in DAC of ESP32 no external DAC required.
esphome:
  name: actron-keypad2
  friendly_name: Actron Keypad2

  includes:
    - led_proto2.h

esp32:
  board: m5stack-core-esp32
  framework:
    type: arduino

# Enable logging
logger:
  level: DEBUG #INFO

# Enable Home Assistant API
api:
  encryption:
    key: !secret esphome_api_key

ota:
  password: !secret esphome_ota_password

wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password

  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: "Aircon-Keypad Fallback Hotspot"
    password: "M3odvzX6U8gI"

captive_portal:

web_server:
  port: 80



#Setup i2c bus to control MCP4725
#https://esphome.io/components/i2c#i2c
#i2c:
#  sda: 25
#  scl: 21
#  scan: true

#MCP4725 output to Send Voltages for key presses 
#https://esphome.io/components/output/mcp4725
output:
  - platform: esp32_dac #mcp4725
    id: dac_output
    pin: GPIO25
    #address: 0x62


##***TESTING***
# Define a number input component to output mV to the DAC
number:
  - platform: template
    name: "DAC Output miliVolts"
    min_value: 0
    max_value: 3240  # Adjust this value according to the DAC's range
    step: 1    # Adjust the step size as needed
    restore_value: true
    optimistic: true
    on_value:
      then:
        lambda: |-
          id(dac_output).set_level((x / 3240.0));
###############################################################

#Voltages adjusted for ESP32 DAC 3.3V output
button:
  - platform: template
    name: "Power"
    icon: mdi:power
    on_press:
      - logger.log: Power Button Pressed
      - lambda: |-
          id(dac_output).set_level((3000.0 / 3240.0));
      - delay: 800ms
      - lambda: |-
          id(dac_output).set_level((0.0 / 3240.0));

  - platform: template
    name: "Fan"
    icon: mdi:fan
    on_press:
      - logger.log: Fan Button Pressed
      - lambda: |-
          id(dac_output).set_level((686.0 / 3240.0));
      - delay: 800ms
      - lambda: |-
          id(dac_output).set_level((0.0 / 3240.0));

  - platform: template
    name: "Temp Up"
    icon: mdi:thermometer-plus
    on_press:
      - logger.log: Temp Up Button Pressed
      - lambda: |-
          id(dac_output).set_level((813.0/ 3240.0));
      - delay: 800ms
      - lambda: |-
          id(dac_output).set_level((0.0 / 3240.0));

  - platform: template
    name: "Temp Down"
    icon: mdi:thermometer-minus
    on_press:
      - logger.log: Temp Down Button Pressed
      - lambda: |-
          id(dac_output).set_level((711.0/ 3240.0));
      - delay: 800ms
      - lambda: |-
          id(dac_output).set_level((0.0 / 3240.0));


  - platform: template
    name: "Mode"
    icon: mdi:air-conditioner
    on_press:
      - logger.log: Mode Button Pressed
      - lambda: |-
          id(dac_output).set_level((889.0/ 3240.0));
      - delay: 800ms
      - lambda: |-
          id(dac_output).set_level((0.0 / 3240.0));

  - platform: template
    name: "Timer"
    icon: mdi:timer
    on_press:
      - logger.log: Timer Button Pressed
      - lambda: |-
          id(dac_output).set_level((780.0/ 3240.0));
      - delay: 800ms
      - lambda: |-
          id(dac_output).set_level((0.0 / 3240.0));

  - platform: template
    name: "Timer Up"
    icon: mdi:timer-plus
    on_press:
      - logger.log: Timer Up Button Pressed
      - lambda: |-
          id(dac_output).set_level((750.0/ 3240.0));
      - delay: 800ms
      - lambda: |-
          id(dac_output).set_level((0.0 / 3240.0));

  - platform: template
    name: "Timer Down"
    icon: mdi:timer-minus
    on_press:
      - logger.log: Timer Down Button Pressed
      - lambda: |-
          id(dac_output).set_level((725.0/ 3240.0));
      - delay: 800ms
      - lambda: |-
          id(dac_output).set_level((0.0 / 3240.0));


#Template sensors will be populated from lambda custom component
sensor:
  - platform: template
    name: "Setpoint Temperature"
    unit_of_measurement: "°C" 
    accuracy_decimals: 1     
    id: setpoint_temp
    state_class: measurement
    icon: mdi:thermometer
  
  - platform: template
    name: "Bit Count"
    id: bit_count
    state_class: measurement
    accuracy_decimals: 0
    
text_sensor:
  - platform: template
    name: "Bit String"
    id: bit_string

binary_sensor:
  - platform: template
    name: "Cool"
    icon: mdi:snowflake
    id: cool
  - platform: template
    name: "Auto"
    icon: mdi:flash-auto
    id: auto_md
  - platform: template
    name: "unk1"
    id: unk1
  - platform: template
    name: "Run"
    icon: mdi:run
    id: run
  - platform: template
    name: "Room"
    id: room
  - platform: template
    name: "unk2"
    id: unk2
  - platform: template
    name: "unk3"
    id: unk3
  - platform: template
    name: "unk4"
    id: unk4
  - platform: template
    name: "Fan Continuos"
    icon: mdi:fan-chevron-up
    id: fan_cont
  - platform: template
    name: "Fan Hi"
    icon: mdi:fan-speed-3
    id: fan_hi
  - platform: template
    name: "Fan Mid"
    icon: mdi:fan-speed-2
    id: fan_mid
  - platform: template
    name: "Fan Low"
    icon: mdi:fan-speed-1
    id: fan_low
  - platform: template
    name: "Room3"
    id: room3
  - platform: template
    name: "Room4"
    id: room4
  - platform: template
    name: "Room2"
    id: room2
  - platform: template
    name: "Heat"
    icon: mdi:fire
    id: heat
  - platform: template
    name: "Room1"
    id: room1
  - platform: template
    name: "unk5"
    id: unk5

#Populate all template sensors from lambda custom component
#First argument of KeypadStatus is the pin number of the ADC input
custom_component:
  - lambda: |-
      auto keypad_status = new KeypadStatus(33,
                                            id(bit_string), id(setpoint_temp), id(bit_count),  
                                            id(cool), id(auto_md), id(unk1), id(run), 
                                            id(room), id(unk2), id(unk3), id(unk4), 
                                            id(fan_cont), id(fan_hi), id(fan_mid), id(fan_low), 
                                            id(room3), id(room4), id(room2), id(heat), 
                                            id(room1), id(unk5)
                                            );
      return {keypad_status};
    components:
      id: keypad_status

and the led_proto2.h file which needs to be saved in your esphome directory:

#include "esphome.h"

#define NPULSE 40  // Define the number of pulses to be captured

class clsLedProto {
  public: // Add this line to change the access level to public for the following members
  enum ClassStatLeds {
    // Enumerations representing the indices in the pulse train for various status LEDs on the HVAC unit
    COOL = 0,
    AUTO = 1,  //RUN_BLINK
    UNK1 = 2,
    RUN = 3,
    ROOM = 4,
    UNK2 = 5,
    UNK3 = 6,
    UNK4 = 7,
    FAN_CONT = 8,  // COOL_AUTO
    FAN_HI = 9,
    FAN_MID = 10,
    FAN_LOW = 11,
    ROOM3 = 12,
    ROOM4 = 13,
    ROOM2 = 14,
    HEAT = 15,
    _3C = 16,
    _3F = 17,
    _3G = 18,
    _3B = 19,
    _3A = 20,
    ROOM1 = 21,
    _3E = 22,
    _3D = 23,
    _2B = 24,
    _2F = 25,
    _2G = 26,
    _2E = 27,
    DP = 28,
    _2C = 29,
    _2D = 30,
    _2A = 31,
    _1D = 32,
    UNK5 = 33,
    _1C = 34,
    _1B = 35,
    _1E = 36,
    _1G = 37,
    _1F = 38,
    _1A = 39,  
    UNK6 = 40
    };

  unsigned long last_intr_us;  // Timestamp of the last interrupt in microseconds
  unsigned long last_work;     // Timestamp of the last work in the main loop in microseconds
  char pulse_vec[NPULSE];      // Temporary storage for the pulse train read during the interrupt
  volatile unsigned char nlow; // Counter for the number of low pulses read
  volatile unsigned char nbits; // Counter for the number of bits read to be published to Home Assistant (volatile means can be changed externally)
  volatile unsigned char dbg_nerr; // Counter for the number of errors (volatile means can be changed externally)
  volatile bool do_work;       // Flag indicating whether there's work to be done in the main loop
  bool data_error;             // Flag indicating whether there's been a data error
  bool newdata;                // Flag indicating whether there's new data to be processed
  char p[NPULSE];              // Storage for the most recent stable pulse train read from the unit  

  //Interrupt Handler
  void handleIntr() {
    auto nowu = micros();           // Stores the current microsecond count at the time of the interrupt.
    unsigned long dtu = nowu - last_intr_us;  // Calculates the time difference in microseconds since the last interrupt.
    last_intr_us = nowu;           // Updates the last interrupt timestamp to the current timestamp.
    if (dtu > 3500) {
      // Do nothing, as this is assumed to be the start of the start bit
      data_error = false;  // Reset the data error flag
      return;
    }
    if (dtu >= 2700) {
    // Start bit detected, reset bit_count
      nlow = 0;
    } else {
    // Data bit detected
    if (nlow >= NPULSE) {
      //ESP_LOGD("custom","Too many pulses: %d", nlow);  //Adding this makes unstable - guess shoudln't log in ISR (maybe increment counter rather)
      data_error = true;  // Set the data error flag
      ++dbg_nerr;  // Increment the error counter
      nlow = NPULSE;  // Ensures that nlow does not exceed NPULSE, resetting it to NPULSE if it does.
    }  
    pulse_vec[nlow] = dtu < 1000;      // Records a '1' or '0' in the pulse vector based on the time difference being less than 800 microseconds.
    ++nlow;       // Increments the nlow counter, indicating a new pulse has been recorded.
    do_work = 1;  // Sets the do_work flag to true, indicating that there's work to be done in the main loop.
    }
  }
  

  char decode_digit(uint8_t hex_value) {
  //This function takes a hex value representing a digit on the display and returns the corresponding character
  //Using conventional segment display values, the hex values are as follows:  
    switch (hex_value) {
      case 0x3F: return '0';
      case 0x06: return '1';
      case 0x5B: return '2';
      case 0x4F: return '3';
      case 0x66: return '4';
      case 0x6D: return '5';
      case 0x7C: return '6';
      case 0x07: return '7';
      case 0x7F: return '8';
      case 0x67: return '9';
      case 0x73: return 'P';  // 'P' is a special case
      default: return '?';   // Return '?' for unrecognized hex values
    }
  }

float get_display_value() {
    uint8_t digit1_bits = (p[_1G] << 6) | (p[_1F] << 5) | (p[_1E] << 4) | (p[_1D] << 3) | (p[_1C] << 2) | (p[_1B] << 1) | p[_1A];
    uint8_t digit2_bits = (p[_2G] << 6) | (p[_2F] << 5) | (p[_2E] << 4) | (p[_2D] << 3) | (p[_2C] << 2) | (p[_2B] << 1) | p[_2A];
    uint8_t digit3_bits = (p[_3G] << 6) | (p[_3F] << 5) | (p[_3E] << 4) | (p[_3D] << 3) | (p[_3C] << 2) | (p[_3B] << 1) | p[_3A];

    std::string display_str;
    display_str += decode_digit(digit1_bits);
    display_str += decode_digit(digit2_bits);
    display_str += decode_digit(digit3_bits);

    for (char c : display_str) {
        if (!isdigit(c) ) return -1.0f;  // return -1 if any character is not a digit
    }
    float display_value = std::stof(display_str);  // Convert string to float
    if (p[DP]) display_value *= 0.1f;              // Apply decimal point if DP bit is set
    return display_value;
    }
  
  void mloop() {
    unsigned long now = micros();  // Get the current microsecond count
    if (do_work) {      // If there's work to do (set by handleIntr())
      do_work = 0;      // Reset the work flag
      last_work = now;  // Update the last work time to now
    } else {
      unsigned long dt = now - last_work;  // Calculate the time since last work
      if (dt > 40000 && nlow) {  // If more than 40000 microseconds have passed and there are pulses recorded
        nbits = nlow;            // Set the number of bits to the number of pulses recorded
        nlow = 0;                // Reset the pulse counter (BCK added this line)
        if (nbits ==  40 && !data_error ) {  // If exactly 40 pulses have been recorded (BCK changed from 42. Sometimes we get 41 bits and this has invalid data)
          if(memcmp(p, pulse_vec, sizeof p) != 0) {  // If the pulse data has changed
            newdata = true;           // Set the newdata flag for the publish_state() call
            //for (int n = 0; n < 45; ++n){       // Loop through each element of the pulse vector
            //  if (p[n] != pulse_vec[n]) ESP_LOGD("custom","%d: %d, ", n, pulse_vec[n]);  // Log the changed data
            //}
            memcpy(p, pulse_vec, sizeof p);  // Copy the new pulse data           
          }
        } else {
          ESP_LOGD("custom","Only %d bits received (Or data error)", nbits);  // Log the number of bits received
        }
        last_work = now;  // Update the last work time to now
      }
    }
  }
};

clsLedProto ledProto;  // Instantiate a clsLedProto object named ledProto

void handleInterrupt() {
    // Global function to handle interrupts and call the appropriate method on ledProto
    ledProto.handleIntr();  
}

class KeypadStatus : public Component{
private:
  TextSensor *bitString   ;
  Sensor *setpoint_temp    ;
  Sensor *bitcount        ;
  BinarySensor *cool      ;
  BinarySensor *auto_md      ;
  BinarySensor *unk1      ;
  BinarySensor *run       ;
  BinarySensor *room      ;
  BinarySensor *unk2      ;
  BinarySensor *unk3      ;
  BinarySensor *unk4      ;
  BinarySensor *fan_cont  ;
  BinarySensor *fan_hi    ;
  BinarySensor *fan_mid   ;
  BinarySensor *fan_low   ;
  BinarySensor *room3     ;
  BinarySensor *room4     ;
  BinarySensor *room2     ;
  BinarySensor *heat      ;
  BinarySensor *room1     ;
  BinarySensor *unk5      ;

public:
  int adc_pin;
  float get_setup_priority() const override { return esphome::setup_priority::IO; }
  KeypadStatus(int adc_pin             ,  //Pin for ADC connection
               TextSensor *bitString   ,
               Sensor *setpoint_temp    ,
               Sensor *bitcount        ,
               BinarySensor *cool      ,
               BinarySensor *auto_md ,
               BinarySensor *unk1      ,
               BinarySensor *run       ,
               BinarySensor *room      ,
               BinarySensor *unk2      ,
               BinarySensor *unk3      ,
               BinarySensor *unk4      ,
               BinarySensor *fan_cont ,
               BinarySensor *fan_hi    ,
               BinarySensor *fan_mid   ,
               BinarySensor *fan_low   ,
               BinarySensor *room3     ,
               BinarySensor *room4     ,
               BinarySensor *room2     ,
               BinarySensor *heat      ,
               BinarySensor *room1     ,
               BinarySensor *unk5      )  : adc_pin(adc_pin)
  {
    this->bitString    = bitString   ;
    this->setpoint_temp = setpoint_temp;
    this->bitcount     = bitcount    ;
    this->cool         = cool        ;
    this->auto_md    = auto_md   ;
    this->unk1         = unk1        ;
    this->run          = run         ;
    this->room         = room        ;
    this->unk2         = unk2        ;
    this->unk3         = unk3        ;
    this->unk4         = unk4        ;
    this->fan_cont    = fan_cont   ;
    this->fan_hi       = fan_hi      ;
    this->fan_mid      = fan_mid     ;
    this->fan_low      = fan_low     ;
    this->room3        = room3       ;
    this->room4        = room4       ;
    this->room2        = room2       ;
    this->heat         = heat        ;
    this->room1        = room1       ;
    this->unk5         = unk5        ;
  } 

  void setup() override
  {
  // Setup code to configure the pin and attach the interrupt
  //Send adc_pin to log
  ESP_LOGD("custom","adc_pin: %d", adc_pin); 
  //adc_pin = 33;
  pinMode(adc_pin, INPUT);
  attachInterrupt(digitalPinToInterrupt(adc_pin), handleInterrupt, FALLING);
    }

    void loop() override {
      // Main loop for the LedProto Component, processes the pulse train and publishes the state to Home Assistant
      ledProto.mloop();
      // Initialize an empty string
      std::string text;
      
      if (ledProto.newdata) {  // Publish the text to the TextSensor
      // Loop through each element of the char array
        text = "";
        for(int i = 0; i < NPULSE; ++i) {
          // Append '0' or '1' to the string based on the value of each element
          text += (ledProto.p[i] ? '1' : '0');
        }
        bitString->publish_state(text);
        ledProto.newdata = false;
      
      // Publish the display value as a number
      float display_value = ledProto.get_display_value();
      setpoint_temp->publish_state(display_value);
//      bitcount->publish_state(ledProto.nbits);
      bitcount->publish_state(ledProto.dbg_nerr); //Changed to publish the error count instead of the bit count

      // Publish the status of each LED as a binary sensor (convert to boolean with check for 0)
      cool->publish_state(ledProto.p[clsLedProto::COOL] != 0);
      auto_md->publish_state(ledProto.p[clsLedProto::AUTO] != 0);
      unk1->publish_state(ledProto.p[clsLedProto::UNK1] != 0);
      run->publish_state(ledProto.p[clsLedProto::RUN] != 0);
      room->publish_state(ledProto.p[clsLedProto::ROOM] != 0);
      unk2->publish_state(ledProto.p[clsLedProto::UNK2] != 0);
      unk3->publish_state(ledProto.p[clsLedProto::UNK3] != 0);
      unk4->publish_state(ledProto.p[clsLedProto::UNK4] != 0);
      fan_cont->publish_state(ledProto.p[clsLedProto::FAN_CONT] != 0);
      fan_hi->publish_state(ledProto.p[clsLedProto::FAN_HI] != 0);
      fan_mid->publish_state(ledProto.p[clsLedProto::FAN_MID] != 0);
      fan_low->publish_state(ledProto.p[clsLedProto::FAN_LOW] != 0);
      room3->publish_state(ledProto.p[clsLedProto::ROOM3] != 0);
      room4->publish_state(ledProto.p[clsLedProto::ROOM4] != 0);
      room2->publish_state(ledProto.p[clsLedProto::ROOM2] != 0);
      heat->publish_state(ledProto.p[clsLedProto::HEAT] != 0);
      room1->publish_state(ledProto.p[clsLedProto::ROOM1] != 0);
      unk5->publish_state(ledProto.p[clsLedProto::UNK5] != 0);

      }
    }
};

All of this is available on Github (in a very draft format) here.
The images folder has a few more photos.

This is all currently functional - but I think there may still be a few issues with the data read back from the Actron Pulses - as I still get some invalid responses.

WOW. Tremendous progress in such a short period of time. Hopefully, I can get mine together over the next couple of days to start playing.

Thank you for sharing your progress.

Thanks for sharing your latest progress.
I will try and put mine together soon too.

Wow that’s some great stuff @brentk !!!

Really appreciate you sharing all the progress you have made. I have gotten a bit side tracked and haven’t had a chance to play around anymore but looks look with your fantastic diagrams and code that I should be able to get something working.

Really like the idea of using the DAC in the atom lite, might have to get one of those and replicate your setup.

I am looking at the breadboard view and I have a question. I understand that horizontal resistor is the 20k one. However from the 2 mounted “vertically” which one is the 1.2K and which one is the 4.7K? (see pic).

Is the one closest to the green part the 1.2K (the one circled in the picture)?

Yes - the one circled is 1.2k. You need to compare it with the circuit diagram Green plug pins are numbered 1-4 from the bottom. Bottom leg of 1.2k connects to pin 1 (follow the lines) and top leg of 4.7k connects to pin 2.

Doesn’t have to be an Atom - I think all the original ESP32 and ESP32-S2 chips include a DAC, just check if the pins are exposed on the board.

Thank for the information.
One more question - the green part what is its actual name? So i can look it up at Jaycar :slight_smile:

Use whatever you want from here:
https://www.jaycar.com.au/cables-connectors/terminal-blocks-headers/terminal-blocks/c/1HA

I used a pluggable header and terminal block

One more question - looking at the Schematic, Veroboard, Photo1 - they all show 4 wires going to the Atom (or the intention of 4 wires).
However, Installed only shows 3 wires.

Just wanted to make sure and confirm that only 3 wires are required from the PCB to the Atom.

Only 3 wires, the top pin is +15V with status pulses. I just brought that out to a pin as it was easy to do and handy if you want to put an oscilloscope on it.

Great work!

Does it decode only 4 zones? Some wall panels have 8 zones and possibly the internals support 8 anyway, could that be where the invalid responses come from?

How did you connect the wall panel side of the wire, in parallel?

I have 8 zones active on my panel. So after I will need to find how to add the extra 4 zones - but thats after I get my going with the above config.

An update from me.

I have put together the prototype on a breadboard. Uploaded the ESPHome configuration to the Atom.
Next I have removed the aircon control panel from the wall and wired up the Atom (all this after I switch off the power to the aircon of course). Turned the power on.

I can power on and off the aircon from HA.

Not all HA buttons work as they should for me. Some of the HA buttons do the same thing, others work as they should while others do do anything at all. This was just a quick test and I did not take notes on which buttons worked or not.

I guess it might have to do with the model of the aircon I have.

My Actron is a SRV190E ESP Plus, with 8 zones and 2 controllers (I could see the lights on the second controller going on/off as I was pressing buttons in HA). The Aircon controllers I have have are AM7-D8 WC

During my testing I was able to also use the second controller to control the Aircon and HA too (for the buttons that worked).
The Bitstring in HA - I see that changing as I press buttons on the second controller.

Any suggestions on how I can proceed form here and get the buttons working as they should?

Many thanks guys.

Connection is in parallel with existing wall panel(s).
My Actron does not have any zone controls, so I can’t help with decoding that. You will see if you look in the custom component code there are a number of bits where the function is ‘unknown’, these could well be additional zones.

Invalid responses are most likely related to noise - I sometimes read an invalid number of pulses.