Skip to content

Integration examples

Home Assistant~

Use a LUA Script to publish the charger state, power, session energy, temperature, error states as MQTT topics, and accept commands to enable/disable charging and to set the charging current. Optionally announce everything to Home Assistant via MQTT autodiscovery so the entities appear automatically.

Set the values in the script directly overwriting the defaults or from the web UI: Scripting -> components -> "MQTT bridge to Home Assistant".

EVSE identifier is important in case of autodiscovery, as it is used as topic prefix, as node path to uniquely identify the charger and when generating entity ids.

Home Assistant Home Assistant

File /usr/lua/ha_bridge.lua:

local evse = require("evse")
local energymeter = require("energymeter")
local mqtt = require("mqtt")
local json = require("json")

local STATE_NAMES = {
    [evse.STATEA]  = "A",
    [evse.STATEB1] = "B1",
    [evse.STATEB2] = "B2",
    [evse.STATEC1] = "C1",
    [evse.STATEC2] = "C2",
    [evse.STATED1] = "D1",
    [evse.STATED2] = "D2",
    [evse.STATEE]  = "E",
    [evse.STATEF]  = "F",
}

local function truthy(data)
    data = tostring(data):lower()
    return data == "on" or data == "1" or data == "true"
end

local function sanitize_identifier(value)
    local s = tostring(value or ""):lower()
    if not s:match("^[a-z0-9]+$") then
        error("invalid EVSE identifier '" .. tostring(value) ..
              "': use only a-z and 0-9 (no spaces, dashes, dots or other characters)")
    end
    return s
end

return {
    id = "ha_bridge",
    name = "MQTT bridge to Home Assistant",
    description = "Home Assistant MQTT bridge with entity autodiscovery",
    params = {
        uri = { type = "string",  name = "Broker URI", default = "mqtt://192.168.1.10:1883" },
        username = { type = "string",  name = "Username", default = "user" },
        password = { type = "string",  name = "Password", default = "pass" },
        identifier = { type = "string",  name = "EVSE identifier", default = "evse" },
        interval = { type = "number",  name = "Publish interval (s)", default = 5 },
        discovery = { type = "boolean", name = "Publish HA discovery", default = true },
    },

    start = function(params)
        print("starting Home Assistant bridge...")

        local raw = params.identifier
        if raw == nil or raw == "" then raw = "evse" end
        local id = sanitize_identifier(raw)
        local interval_ms = math.max(1, math.floor(params.interval or 10)) * 1000

        local state_topic = id .. "/state"
        local cmd_topic = id .. "/+/set"

        -- empty credential fields -> nil (anonymous broker)
        local user = (params.username ~= "") and params.username or nil
        local pass = (params.password ~= "") and params.password or nil

        local client = mqtt.client(params.uri, user, pass)

        -- Home Assistant discovery: one device, all entities read the single
        -- state topic through value_template. Config topics are retained.
        local function announce()
            local device = { identifiers = { id }, name = id, manufacturer = "esp32-evse", model = "esp32-evse" }
            -- slug becomes the entity id suffix: <domain>.<id>_<slug>
            local function disco(kind, slug, cfg)
                cfg.object_id = id .. "_" .. slug -- HA derives entity_id from object_id
                cfg.unique_id = id .. "_" .. slug
                cfg.device = device
                client:publish("homeassistant/" .. kind .. "/" .. id .. "/" .. slug .. "/config",
                    json.encode(cfg), 0, 1)
            end

            disco("sensor", "state", {
                name = "State", state_topic = state_topic,
                value_template = "{{ value_json.state }}", icon = "mdi:ev-station" })
            disco("sensor", "power", {
                name = "Power", state_topic = state_topic,
                value_template = "{{ value_json.power }}",
                unit_of_measurement = "W", device_class = "power", state_class = "measurement" })
            disco("sensor", "session_energy", {
                name = "Session energy", state_topic = state_topic,
                value_template = "{{ value_json.session_energy }}",
                unit_of_measurement = "Wh", device_class = "energy", state_class = "total_increasing" })
            disco("sensor", "temperature", {
                name = "Temperature", state_topic = state_topic,
                value_template = "{{ value_json.temperature }}",
                unit_of_measurement = "°C", device_class = "temperature", state_class = "measurement" })
            disco("binary_sensor", "charging", {
                name = "Charging", state_topic = state_topic,
                value_template = "{{ 'ON' if value_json.charging else 'OFF' }}",
                payload_on = "ON", payload_off = "OFF", device_class = "battery_charging" })
            disco("binary_sensor", "error", {
                name = "Error", state_topic = state_topic,
                value_template = "{{ 'ON' if value_json.error else 'OFF' }}",
                payload_on = "ON", payload_off = "OFF", device_class = "problem" })
            disco("switch", "charging_enabled", {
                name = "Charging enabled", state_topic = state_topic,
                value_template = "{{ 'ON' if value_json.enabled else 'OFF' }}", icon = "mdi:ev-plug-type2",
                command_topic = id .. "/enabled/set",
                payload_on = "ON", payload_off = "OFF", state_on = "ON", state_off = "OFF" })
            disco("number", "charging_current_limit", {
                name = "Charging current limit", state_topic = state_topic,
                value_template = "{{ value_json.current }}", icon = "mdi:current-ac",
                command_topic = id .. "/current/set",
                min = 6, max = evse.getmaxchargingcurrent(), step = "0.1",
                unit_of_measurement = "A", mode = "slider" })
        end

        return coroutine.create(function()
            repeat
                print("connecting...")
            until client:connect()
            print("connected!")

            if params.discovery then announce() end

            -- one subscription with a wildcard; dispatch by the middle segment
            client:subscribe(cmd_topic, function(topic, data)
                -- topic is "<id>/<what>/set" -> extract <what>
                local what = string.sub(topic, #id + 2)
                what = string.sub(what, 1, #what - 4) -- strip "/set"

                local methods = {
                    enabled = function() evse.setenabled(truthy(data)) end,
                    current = function()
                        local amps = tonumber(data)
                        if amps then
                            -- setchargingcurrent raises on out-of-range values
                            if not pcall(evse.setchargingcurrent, amps) then
                                print("ha_bridge: rejected current '" .. tostring(data) .. "'")
                            end
                        end
                    end,
                }
                if methods[what] then methods[what]() end
            end)

            while true do
                local state = evse.getstate()
                local payload = {}
                payload.state = STATE_NAMES[state] or "?"
                payload.charging = state == evse.STATEC2 or state == evse.STATED2
                payload.enabled = evse.getenabled()
                payload.error = evse.geterror() > 0
                payload.power = energymeter.getpower()
                payload.current = evse.getchargingcurrent()
                payload.session_energy = energymeter.getconsumption()
                payload.temperature = evse.gethightemperature()

                client:publish(state_topic, json.encode(payload), 0, 1) -- retained
                coroutine.yield(interval_ms)
            end
        end)
    end
}

Note

Don't forget to add component.register(require("ha_bridge")) to your /usr/lua/init.lua file.

ThingsBoard cloud MQTT~

Implementing an integration to ThingsBoard cloud with a LUA Script:

File /usr/lua/thingsboard.lua:

local evse = require("evse")
local energymeter = require("energymeter")
local mqtt = require("mqtt")
local json = require("json")

return {
    id = "thingsboard",
    name = "ThingsBooard",
    description = "ThingsBooard MQTT connector",
    params = {
        token = { type = "string", name = "Access token" }
    },
    start = function(params)
        print("starting thingsboard connector...")

        if not params.token then
            error("require access token")
        end

        local client = mqtt.client("mqtt://demo.thingsboard.io:1883", params.token)

        return coroutine.create(function()
            repeat
                print("connecting...")
            until client:connect()
            print("conected!")            

            client:subscribe("v1/devices/me/rpc/request/+", function(topic, data)
                local payload = json.decode(data)
                local reqid = string.sub(topic, #"v1/devices/me/rpc/request/" + 1)
                local restopic = "v1/devices/me/rpc/response/" .. reqid

                function response(value)
                    client:publish(restopic, json.encode({ value = value }))
                end

                local methods = {
                    ["getEnabled"] = function()
                        response(evse.getenabled())
                    end,
                    ["setEnabled"] = function(param)
                        evse.setenabled(param)
                    end,
                    ["getChargingCurrent"] = function()
                        response(evse.getchargingcurrent())
                    end,
                    ["setChargingCurrent"] = function(param)
                        evse.setchargingcurrent(param)
                    end
                }

                if methods[payload.method] ~= nil then
                    methods[payload.method](payload.params)
                end
            end)

            while true do
                local payload = {}
                local state = evse.getstate()
                payload["session"] = state >= evse.STATEB1 and state <= evse.STATED2
                payload["charging"] = state == evse.STATEC2 or state == evse.STATED2
                payload["enabled"] = evse.getenabled()
                payload["available"] = evse.getavailable()
                payload["error"] = evse.geterror() > 0
                payload["consumption"] = energymeter.getconsumption()
                payload["power"] = energymeter.getpower()
                payload["temperature"] = evse.gethightemperature()
                payload["uptime"] = os.clock()
                payload["currentL1"], payload["currentL2"], payload["currentL3"] = energymeter.getcurrent()
                payload["voltageL1"], payload["voltageL3"], payload["voltageL3"] = energymeter.getvoltage()

                client:publish("v1/devices/me/telemetry", json.encode(payload))

                coroutine.yield(60000)
            end
        end)
    end
}

Note

Don't forget to add component.register(require("thingsboard")) to your /usr/lua/init.lua file.

EVCC~

Here's a mapping between REST API and EVCC. Replace EVSE_IP with your charger's address:

chargers:
  - name: esp32evse
    type: custom                      # continuous current control + built-in meter

    # status: map ESP32-EVSE state (A,B1,B2,C1,C2,D1,D2,E,F) to EVCC's A..F
    status:
      source: http
      uri: http://EVSE_IP/api/v1/state
      jq: .state[0:1]                 # first letter only: B1/B2->B, C1/C2->C, D1/D2->D

    enabled:                          # is charging enabled? -> bool
      source: http
      uri: http://EVSE_IP/api/v1/state
      jq: .enabled

    enable:                           # start/stop charging
      source: http
      uri: http://EVSE_IP/api/v1/state/enabled
      method: POST
      headers:
        - content-type: application/json   # required, else the firmware returns 415
      body: "{{if .enable}}true{{else}}false{{end}}"

    maxcurrent:                       # integer A
      source: http
      uri: http://EVSE_IP/api/v1/state/charging-current
      method: POST
      headers:
        - content-type: application/json
      body: "{{.maxcurrent}}"

    maxcurrentmillis:                 # fractional A -> "dimmable"; device rounds to 0.1 A
      source: http
      uri: http://EVSE_IP/api/v1/state/charging-current
      method: POST
      headers:
        - content-type: application/json
      body: "{{.maxcurrentmillis}}"

    # ---- built-in meter ----
    power:
      source: http
      uri: http://EVSE_IP/api/v1/state
      jq: .power                      # W
    energy:
      source: http
      uri: http://EVSE_IP/api/v1/state
      jq: .totalConsumption / 3600000 # Ws -> kWh (lifetime, monotonic)
    currents:                         # must be exactly three entries
      - { source: http, uri: "http://EVSE_IP/api/v1/state", jq: .current[0] }
      - { source: http, uri: "http://EVSE_IP/api/v1/state", jq: .current[1] }
      - { source: http, uri: "http://EVSE_IP/api/v1/state", jq: .current[2] }
    voltages:
      - { source: http, uri: "http://EVSE_IP/api/v1/state", jq: .voltage[0] }
      - { source: http, uri: "http://EVSE_IP/api/v1/state", jq: .voltage[1] }
      - { source: http, uri: "http://EVSE_IP/api/v1/state", jq: .voltage[2] }

loadpoints:
  - title: Garage
    charger: esp32evse
    mode: pv
    mincurrent: 6
    maxcurrent: 16                    # <= the device's maxChargingCurrent
    phases: 1                         # set to your wiring (1 or 3)

Note

This hasn't been thoroughly tested. Please give feedback if you try it.

Note

ESP32-EVSE stores charging current at 0.1 A (deci-amp) granularity, so the "milliamp" path really gives 100 mA steps. It's genuinely continuously dimmable, just not to 1 mA.

Energy uses totalConsumption, not consumption. EVCC treats the charger's energy as a monotonic counter and diffs it for session energy; consumption resets to 0 each session and would produce negative diffs, so use the lifetime counter.

Set ESP32-EVSE so it doesn't require authorization for charging. If HTTP Basic credentials are set, add an auth: block (type: basic, user:, password:) to each plugin.

ESPHome~

The AT commands engine can be used to communicate from other open source projects like ESPHome with ESP32-EVSE.

This external ESPHome component implements accessing most EVSE parameters as ESPHome sensors, binary sensors, text sensors, switches, buttons, number inputs. These can appear in ESPHome and Home Assistant as native components.

It is intended to be used on a separate ESP32 microcontroller connected to ESP32-EVSE thorugh an UART. The biggest advantage here is that ESP32-EVSE doesn't have to deal with any of the above, can keep working independently as a mission critical piece of hardware, all this functionality can run separately without generating extra load on the charger.

The Things Network (LoRaWAN)~

Communication over LoRaWAN network, using a Dragino RS485-LN (ModBUS to LoRaWAN Converter).

Set RS-485 serial interface type for ModBUS mode, and default parameters for Dragino are: baud rate: 9600, data bits: 8, stop bits: 1, parity: disable.

Dragino configuration

Serial port parameters: AT+BAUDR, AT+PARITY, AT+DATABIT, AT+STOPBIT commands.

AT+TDC=60000
AT+DECRYPT=0
AT+MBFUN=0
AT+BAUDR=9600
AT+PARITY=0
AT+DATABIT=8
AT+STOPBIT=0
AT+DATAUP=0
AT+PAYVER=1
AT+CHS=0
AT+RXMODE=0,0
AT+COMMAND1=01 03 00 64 00 04, 1
AT+DATACUT1=13,2,4~9+11~11
AT+CMDDL1=0
AT+COMMAND2=01 03 00 c8 00 03, 1
AT+DATACUT2=11,2,4~9
AT+CMDDL2=0
AT+COMMAND3=01 03 00 cf 00 0c, 1
AT+DATACUT3=29,2,4~27
AT+CMDDL3=0
AT+COMMAND4=01 03 01 90 00 04, 1
AT+DATACUT4=13,2,4~7+10~11
AT+CMDDL4=0

The Things Network

After sucesffuly adding Dragino to your app, set JavaScript payload decoder:

function decodeUplink(input) {
  function uint32(idx) {
   return input.bytes[idx] << 24 | input.bytes[idx + 1] << 16 | input.bytes[idx + 2] << 8 | input.bytes[idx + 3];
  }

  function uint16(idx) {
   return input.bytes[idx] << 8 | input.bytes[idx + 1];
  }

  function toSint16(value) {
    if ((value & 1 << 15) > 0) { // value is negative (16bit 2's complement)
      value = ((~value) & 0xffff) + 1; // invert 16bits & add 1 => now positive value
      value = value * -1;
    }
    return value;
  }

  return {
    data: {
      state: String.fromCharCode(input.bytes[1]) + (input.bytes[2] > 0 ? String.fromCharCode(input.bytes[2]) : ''),
      errors: uint32(3),
      enabled: input.bytes[7] === 1,
      power: uint16(8),
      consumption: uint32(10),
      voltage: [
        uint32(14) / 1000,
        uint32(18) / 1000,
        uint32(22) / 1000,
      ],
      current: [
        uint32(26) / 1000,
        uint32(30) / 1000,
        uint32(34) / 1000,
      ],
      uptime: uint32(38),
      temperature: toSint16(uint16(42)) / 100,
    },
    warnings: [],
    errors: []
  };
}

After setup, you should see live data in TTN console:

TTN live data

See also~