diff --git a/README.md b/README.md index 27b1ffb..dbdfd9b 100644 --- a/README.md +++ b/README.md @@ -1 +1,32 @@ -# esphome-emporia-vue \ No newline at end of file +# ESPHome Emporia Vue Utility Connect Unofficial Firmware + +This is an unauthorized and unoffical firmware for the Emporia View Utility Connect device that reports energy usage to Home Assistant and completely divorces the device from Emporia's servers. + +## Disclaimer + +This software is of generally poor quality and should not be used by anyone. When you install the software on your device, it +will no longer report data to Emporia. You should backup the original Emporia firmware before installing this. + +## Installation + +Connect a your USB to serial adapter to the port marked "P3" as follows: + +| Pin | Description | USB-Serial port | +| --- | --------- | --------------- | +| 1 | IO0 | RTS | +| 2 | EN | DTR | +| 3 | GND | GND | +| 4 | TX | RX | +| 5 | RX | TX | +| 6 | +5v | +5v | + +Note that pin 6 (the pin just above the text "EmporiaEnergy") is 5 volts, not 3.3v. Use caution not to apply 5V to the wrong pin or +the magic smoke may come out. You may want to not connect pin 6 at all and instead plug the device into a usb port to provide power, +a portable USB battery pack works well for this. + +Instead of connecting IO0 and EN, you can simply short IO0 to ground while connecting power to get the device into bootloader mode. + +Download [emporia_vue_utility.h](src/emporia_vue_utility.h) and either one of [vue-utility-full.yaml](src/vue-utility-full.yaml) or +[vue-utility-minimal.yaml](src/vue-utility-minimal.yaml). + +Execute `esphome run vue-utility-*.yaml` to build and install diff --git a/docs/pinout.md b/docs/pinout.md new file mode 100644 index 0000000..455a47e --- /dev/null +++ b/docs/pinout.md @@ -0,0 +1,56 @@ +## Pinouts + +![Emporia Vue Utility device](device_800.jpg) + +### P1 + +JTAG header for the ESP32 module. The pinout is the same as as the [ESP-Prog](https://docs.espressif.com/projects/espressif-esp-iot-solution/en/latest/hw-reference/ESP-Prog_guide.html) + +| Pin | ESP Pin | ESP Port | Description | +| --- | ------- | -------- | ----------- | +| 1 | | | +3v3 | +| 2 | 13 | GPIO14 | JTAG TMS | +| 3 | | | GND | +| 4 | 16 | GPIO13 | JTAG TCK | +| 5 | | | GND | +| 6 | 23 | GPIO15 | JTAG TDO | +| 7 | | | GND | +| 8 | 14 | GPIO12 | JTAG TDI | +| 9 | | | GND | +| 10 | 3 | | EN | + +### P2 + +Power supply + +| Pin | Description | +| --- | ----------- | +| 1 | +5v | +| 2 | +3v3 | +| 3 | GND | + +### P3 + +ESP32 Serial programming interface. Note that pin 6 is 5 volt, not 3.3 volt. + +| Pin | ESP Pin | ESP Port | +| --- | ------- | -------- | +| 1 | 25 | GPIO0 | +| 2 | 3 | EN | +| 3 | | GND | +| 4 | 35 | TX | +| 5 | 35 | RX | +| 6 | | +5v | + + +### P4 + +JTAG interface to the MGM111 module + +### P5 + +| Pin | ESP Pin | MGM111 Pin | Description | +| --- | ------- | ---------- | ----------- | +| 1 | GPIO21 | | ESP RX | +| 2 | GPIO22 | | ESP TX | +| 3 | | | GND | diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..034785c --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,55 @@ +# Protocol details + +Each message sent or received begins with a `$` (hex 24). Messages end with a carrage return (`\r` / hex 0D). + +### Message types + +| Msg char | Hex value | Description | +| -------- | --------- | ----------- | +| r | 0x72 | Get meter reading | +| j | 0x6a | Join the meter | +| m | 0x6d | Get mac address (of the MGM111)| +| i | 0x69 | Get install code | +| f | 0x66 | Get firmware version | + +## Sending messages + +Messages from the ESP to the MGM111 have just the previously mentioned `$` starting delimiter, then a message type character and the +ending `\r` delimiter. Each message is therefore exactly 3 bytes long. For example, to get the a mac address, send the hex bytes +`24 6D 0D` (`$m\r`) + +## Receiving responses + +Response format bytes: + +| 0 | 1 | 2 | 3 | ... | x | +| -- | -- | -- | -- | --- | - | +| `$` | 0x01 | *\* | *\* | *\...* | `\r` | + +Byte 1, 0x01 indicates that this is a response +Byte 2 is the same messages type that was used to trigger this response +Byte 3 is the length of the payload that follows +Bytes 4+ are the payload of the response +The final byte is the terminator + +Ex: +`24 01 6D 08 11 22 33 44 55 66 77 88 0D` + +#### Mac address response payload +The mac address reponse bytes are in reverse order, if the device responds with `11 22 33 44 55 66 77 88` then the mac address is +`88:77:66:55:44:33:22:11` + +#### Install code response payload +The install code bytes are also swapped, see mac address response above + +#### Meter reading response payload + +| Bytes | Meaning | +| ----- | ------- | +| 0 - 3 | Unknown, usually zeros | +| 4 - 8 | Watt hours consumed. Sometimes an invalid number greater than `0x0040 0000` is returned, which is not well understood | +| 9 - 45 | Unknown, seems to always be zeros | +| 46 - 50 | Unknown, seems to typically be `0x0000 0001` | +| 51 - 55 | Potentially a Watt-Hour to kWh divisor, typically `0x0000 0x03E8` (1000) | +| 56 - 60 | Current watts being consumed | +| 61+ | Unknown | diff --git a/src/emporia_vue_utility.h b/src/emporia_vue_utility.h new file mode 100644 index 0000000..9211c50 --- /dev/null +++ b/src/emporia_vue_utility.h @@ -0,0 +1,232 @@ +#include "esphome.h" +#include "sensor.h" +#include "esphome/components/gpio/output/gpio_binary_output.h" + +class EmporiaVueUtility : public Component, public UARTDevice { + public: + EmporiaVueUtility(UARTComponent *parent): UARTDevice(parent) {} + Sensor *kWh = new Sensor(); + Sensor *W = new Sensor(); + + const char *TAG = "EmporiaVue"; + + struct MeterReading { + char header; + char is_resp; + char msg_type; + uint8_t data_len; + byte unknown1[4]; + byte watt_hours[4]; + byte unknown2[36]; + byte unknown3[4]; + byte unknown4[4]; + byte unknown5[4]; + byte watts[4]; + byte unknown6[48]; + byte unknown7[4]; + }; + + union input_buffer { + byte data[260]; + struct Message msg; + struct MeterReading mr; + } input_buffer; + + uint16_t pos = 0; + uint16_t data_len; + + time_t last_meter_reading = 0; + time_t last_meter_requested = 0; + time_t last_meter_join = 0; + const time_t meter_reading_interval = 5; + const time_t meter_join_interval = 30; + + // Reads and logs everything from serial until it runs + // out of data or encounters a 0x0d byte (ascii CR) + void dump_serial_input(bool logit) { + while (available()) { + if (input_buffer.data[pos] == 0x0d) { + break; + } + input_buffer.data[pos] = read(); + if (pos == sizeof(input_buffer.data)) { + if (logit) { + ESP_LOGE(TAG, "Filled buffer with garbage:"); + ESP_LOG_BUFFER_HEXDUMP(TAG, input_buffer.data, pos, ESP_LOG_ERROR); + } + pos = 0; + } else { + pos++; + } + } + if (pos > 0 && logit) { + ESP_LOGE(TAG, "Skipped input:"); + ESP_LOG_BUFFER_HEXDUMP(TAG, input_buffer.data, pos-1, ESP_LOG_ERROR); + } + pos = 0; + data_len = 0; + } + + size_t read_msg() { + if (!available()) { + return 0; + } + + while (available()) { + char c = read(); + uint16_t prev_pos = pos; + input_buffer.data[pos] = c; + pos++; + + switch (prev_pos) { + case 0: + if (c != 0x24 ) { // 0x24 == "$", the start of a message + ESP_LOGE(TAG, "Invalid input at position %d: 0x%x", pos, c); + dump_serial_input(true); + pos = 0; + return 0; + } + break; + case 1: + if (c != 0x01 ) { // 0x01 means "response" + ESP_LOGE(TAG, "Invalid input at position %d 0x%x", pos, c); + dump_serial_input(true); + pos = 0; + return 0; + } + break; + case 2: + // This is the message type byte + break; + case 3: + // The 3rd byte should be the data length + data_len = c; + break; + case sizeof(input_buffer.data) - 1: + ESP_LOGE(TAG, "Buffer overrun"); + dump_serial_input(true); + return 0; + default: + if (pos < data_len + 5) { + ; + } else if (c == 0x0d) { // 0x0d == "/r", which should end a message + return pos; + } else { + ESP_LOGE(TAG, "Invalid terminator at pos %d 0x%x", pos, c); + ESP_LOGE(TAG, "Following char is 0x%x", read()); + dump_serial_input(true); + return 0; + } + } + } // while(available()) + + return 0; + } + + uint32_t byte_32_read(const byte *inb) { + uint32_t x = 0; + x += inb[0] << 24; + x += inb[1] << 16; + x += inb[2] << 8; + x += inb[3]; + return x; + } + + void handle_resp_meter_reading() { + uint32_t input_value; + struct MeterReading *mr; + mr = &input_buffer.mr; + + // Read the watt-hours value + input_value = byte_32_read(mr->watt_hours); + if (input_value >= 4194304) { + ESP_LOGE(TAG, "Invalid watt-hours data"); + ESP_LOG_BUFFER_HEXDUMP(TAG, mr->watt_hours, 4, ESP_LOG_ERROR); + ESP_LOGE(TAG, "Full packet:"); + ESP_LOG_BUFFER_HEXDUMP(TAG, input_buffer.data, pos, ESP_LOG_ERROR); + } else { + kWh->publish_state(float(input_value) / 1000.0); + } + + // Read the instant watts value + input_value = byte_32_read(mr->watts); + if (input_value == 8388608) { // Appears to be "missing data" message (0x00 80 00 00) + ESP_LOGD(TAG, "Instant Watts value missing"); + } + else if (input_value >= 131072) { + ESP_LOGE(TAG, "Unreasonable watts value %d", input_value); + ESP_LOG_BUFFER_HEXDUMP(TAG, mr->watts, 4, ESP_LOG_ERROR); + ESP_LOGE(TAG, "Full packet:"); + ESP_LOG_BUFFER_HEXDUMP(TAG, input_buffer.data, pos, ESP_LOG_ERROR); + } else { + W->publish_state(input_value); + } + } + + void handle_resp_meter_join() { + ESP_LOGD(TAG, "Got meter join response"); + } + + void send_meter_request() { + const byte msg[] = { 0x24, 0x72, 0x0d }; + ESP_LOGD(TAG, "Sending request for meter reading"); + write_array(msg, sizeof(msg)); + } + void send_meter_join() { + const byte msg[] = { 0x24, 0x6a, 0x0d }; + ESP_LOGD(TAG, "Sending meter join"); + write_array(msg, sizeof(msg)); + } + + void setup() override { + write(0x0d); + dump_serial_input(false); + sleep(1); + dump_serial_input(false); + sleep(1); + dump_serial_input(false); + } + + void loop() override { + float curr_kWh; + uint32_t curr_Watts; + int packet_len = 0; + int bytes_rx; + char msg_type = 0; + size_t msg_len = 0; + byte inb; + time_t now; + + msg_len = read_msg(); + now = time(&now); + if (msg_len != 0) { + + msg_type = input_buffer.data[2]; + + switch (msg_type) { + case 'r': // Meter reading + handle_resp_meter_reading(); + last_meter_reading = now; + break; + case 'j': // Meter reading + handle_resp_meter_join(); + break; + default: + ESP_LOGE(TAG, "Unhandled response type '%c'", msg_type); + ESP_LOG_BUFFER_HEXDUMP(TAG, input_buffer.data, msg_len, ESP_LOG_ERROR); + break; + } + pos = 0; + } + + if (now - last_meter_requested > meter_reading_interval) { + send_meter_request(); + last_meter_requested = now; + } + if ((now - last_meter_reading > (meter_reading_interval * 5)) + && (now - last_meter_join > meter_join_interval)) { + send_meter_join(); + last_meter_join = now; + } + } +}; diff --git a/src/vue-utility-full.yaml b/src/vue-utility-full.yaml new file mode 100644 index 0000000..fa1ab39 --- /dev/null +++ b/src/vue-utility-full.yaml @@ -0,0 +1,127 @@ +############### +# Full config # +############### + +esphome: + name: vue-utility + platform: ESP32 + board: esp-wrover-kit + includes: + - emporia_vue_utility.h + +# Add your own wifi credentials +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + +ota: + +logger: + +api: + +mqtt: + broker: !secret mqtt_broker + id: vue-utility + username: !secret mqtt_username + password: !secret mqtt_password + discovery: False # Only if you use the HA API usually + +# This uart connects to the MGM111 +uart: + id: emporia_uart + rx_pin: GPIO21 + tx_pin: GPIO22 + baud_rate: 115200 + +sensor: + - platform: custom + lambda: |- + auto vue = new EmporiaVueUtility(id(emporia_uart)); + App.register_component(vue); + return {vue->kWh, vue->W}; + sensors: + - name: "kWh" + id: kwh + accuracy_decimals: 3 + state_class: total_increasing + device_class: energy + # Reduce the rate of reporting the value to + # once every 5 minutes and/or when 0.1 kwh + # have been consumed, unless the fast_reporting + # button has been pushed + filters: + - or: + - throttle: 5m + - delta: 0.1 + - lambda: |- + if (id(fast_reporting)) return(x); + return {}; + on_raw_value: + then: + lambda: |- + ESP_LOGI("Vue", "kWh = %0.3f", x); + - name: "Watts" + id: watts + unit_of_measurement: "W" + accuracy_decimals: 0 + state_class: measurement + device_class: power + # Report every 5 minutes or when +/- 20 watts + filters: + - or: + - throttle: 5m + - delta: 20 + - lambda: |- + if (id(fast_reporting)) return(x); + return {}; + on_raw_value: + then: + lambda: |- + ESP_LOGI("Vue", "Watts = %0.3f", x); + + +# This gives you a button that temporarily causes results to be +# reported every few seconds instead of on significant change +# and/or every 5 minutes +button: + - platform: template + name: "Fast Reporting" + id: fast_reporting_button + on_press: + - lambda: id(fast_reporting) = true; + - delay: 5min + - lambda: id(fast_reporting) = false; + +# Global value for above button +globals: + - id: fast_reporting + type: bool + restore_value: no + initial_value: "false" + +# This LED is marked D3 on the pcb and is the power led on the case +status_led: + pin: + number: GPIO25 + # It *is* inverted, but being normally on looks better + inverted: false + +# The other two LEDs on the device +# Not currently used by anything +output: + # D2 LED / "Wifi" on the case + - platform: gpio + id: wifi_led + name: "Wifi LED" + pin: + number: GPIO33 + inverted: true + # D1 LED / "connect" on the case + - platform: gpio + id: connect_led + name: "Connect LED" + pin: + number: GPIO32 + inverted: true + diff --git a/src/vue-utility-minimal.yaml b/src/vue-utility-minimal.yaml new file mode 100644 index 0000000..3ea14a2 --- /dev/null +++ b/src/vue-utility-minimal.yaml @@ -0,0 +1,41 @@ +####################### +# Bare minimum config # +####################### + +esphome: + name: vue-utility + platform: ESP32 + board: esp-wrover-kit + includes: + - emporia_vue_utility.h + +# Add your own wifi credentials +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + +ota: + +logger: + +api: + +# This uart connects to the MGM111 +uart: + id: emporia_uart + rx_pin: GPIO21 + tx_pin: GPIO22 + baud_rate: 115200 + +sensor: + - platform: custom + lambda: |- + auto vue = new EmporiaVueUtility(id(emporia_uart)); + App.register_component(vue); + return {vue->kWh, vue->W}; + sensors: + - name: "kWh" + id: kwh + - name: "Watts" + id: watts +