A Rust application that connects locally to an EG4 Hybrid Solar Mini-Split Heat Pump's Tuya WiFi module and bridges all data points to MQTT — including hidden solar/energy DPs that Tuya doesn't expose through their standard integrations.
WARNING: This project was largely created with the assistance of AI (Claude). It is provided as-is with no warranty. Use at your own risk. The author is not liable for any damage to your equipment, loss of data, voided warranties, or any other consequences of using this software. Follow best practices when deploying containers and services in your own homelab environment.
Local control on this EG4/Tuya module is currently flaky enough that it deserves top billing. We have repeatedly seen the heat pump refuse new local TCP connections on port 6668 until the unit is power-cycled, even while the vendor app still appears healthy.
The strongest outside clue so far is TinyTuya's troubleshooting note that Tuya devices only allow one TCP connection at a time and that the TuyaSmart or SmartLife app should be closed before attempting a local connection. That does not prove the app is always the cause here, but it strongly suggests this device family is touchy about session ownership.
Practical implications:
- Treat this bridge as the only intentional local client.
- Avoid manual
nc,telnet, or ad-hoc socket probing of port6668. - Be cautious about leaving the vendor app open while debugging local control.
- Do not assume healthy MQTT means a healthy Tuya session.
The running investigation lives in NETWORK_FLAKINESS.md.
The EG4 Hybrid Solar Mini-Split ships with a Tuya WBR3 WiFi+BLE module for cloud control via the Tuya/Smart Life app. While Tuya's cloud API and integrations like LocalTuya can control basic functions (power, mode, fan speed, temperature), they have significant limitations:
- LocalTuya couldn't map all DPs the way we wanted for Home Assistant
- The solar/energy data points are completely hidden from Tuya's cloud API and standard integrations
- No flexibility in how entities are created in Home Assistant
This bridge connects directly to the device over your local network (no cloud dependency), reads all data points including the hidden ones, and publishes raw values to MQTT where you have full control over entity creation.
Tuya Local Protocol v3.3
┌──────────────┐ TCP:6668 ┌──────────────┐ MQTT ┌──────────┐
│ WBR3 Module │ ◄──────────────────► │ tuya-to-mqtt │ ◄──────────────► │ Broker │
│ (Heat Pump) │ AES-128-ECB │ (this app) │ │ │
└──────────────┘ └──────────────┘ └──────────┘
The bridge:
- Connects to the WBR3 module over your LAN using Tuya's local protocol (version 3.3, AES-128-ECB encrypted, port 6668)
- Queries all data points on startup, then listens for real-time updates
- Publishes each DP value to an MQTT topic using the DP's code name
- Subscribes to MQTT command topics so you can control the device
- Only publishes when a value actually changes (change detection)
- Reconnects automatically with exponential backoff if the connection drops
- Your heat pump's device ID and local key from the Tuya cloud API (use tinytuya to extract these)
- The device's local IP address on your network
- An MQTT broker (Mosquitto, EMQX, etc.)
- Docker (recommended) or Rust toolchain (for building from source)
pip install tinytuya
python -m tinytuya wizardFollow the prompts to link your Tuya developer account. This will generate a devices.json file with your device ID, local key, and DP mapping.
Copy the example and fill in your credentials:
cp devices.json.example devices.jsonEdit devices.json:
- Replace
YOUR_DEVICE_IDwith your actual device ID - Replace
YOUR_LOCAL_KEYwith your actual local key - Replace
192.168.1.100with your heat pump's IP address
Or if you ran tinytuya wizard, copy its output and add the "ip" field.
cp .env.example .envEdit .env with your MQTT broker details:
MQTT_BROKER_HOST=your-mqtt-broker.local
MQTT_BROKER_PORT=1883
MQTT_USERNAME=your_mqtt_user
MQTT_PASSWORD=your_mqtt_pass
HA_DISCOVERY_ENABLED=true
HA_DISCOVERY_PREFIX=homeassistant
LOG_STDOUT_ENABLED=true
LOG_FILE_ENABLED=true
LOG_DIR=/app/logs
LOG_ROTATION=daily
LOG_FILE_PREFIX=tuya-to-mqtt.log
If you're using Home Assistant with the default MQTT discovery prefix, leave HA_DISCOVERY_ENABLED=true. The bridge will publish retained discovery messages so Home Assistant can automatically create one heat pump device with a climate entity and attached entities.
With Docker (recommended):
The included docker-compose.yml builds the bridge from local source by default:
services:
tuya-to-mqtt:
build: .
env_file:
- .env
volumes:
- ./devices.json:/app/devices.json:ro
- ./logs:/app/logs
working_dir: /app
restart: unless-stopped
network_mode: host
logging:
driver: json-file
options:
max-size: "20m"
max-file: "10"Then run:
docker compose up -dContainer logs stay available through docker logs, and the bridge also writes long-lived file logs into ./logs/ so connection failures survive container restarts.
If you prefer the published image instead, replace build: . with:
image: ghcr.io/tfoote000/eg4heatpumptuyatomqtt:latestFrom source:
cargo runFor fast A/B testing on Windows, you can run the bridge natively instead of rebuilding Docker images on every change.
One-click repo items:
- start-native.cmd - builds and starts the native Windows process in the background
- stop-native.cmd - stops the native Windows process
How the native workflow works:
start-native.cmdloads.env, stops the Docker bridge first if it is running, builds the debug binary, and starts the native process in the backgroundstop-native.cmdstops that background process using a PID file in.run/native.pid- Native runs use the same
devices.json,.env, MQTT topics, and Home Assistant discovery IDs as Docker, so Home Assistant sees the same device rather than a duplicate
Useful native commands for manual debugging:
cargo check
cargo runNative runtime logs:
- structured application logs still go into
./logs/ - native stdout goes to
./logs/native-stdout.log - native stderr goes to
./logs/native-stderr.log
This repo also includes a local Home Assistant query helper so you can inspect live HA state from the same workspace without switching repos.
Repo item:
- query-ha.cmd - one-click wrapper around the PowerShell helper
The helper reads local settings from either:
HA_BASE_URLHA_TOKENHA_TOKEN_PATH- or an ignored local file named
.ha-api.env
Start from:
Example local .ha-api.env:
HA_BASE_URL=http://homeassistant.local:8123
HA_TOKEN_PATH=C:\path\to\home_assistant_long_lived_token.txtExamples:
# List live loaded entities
.\query-ha.cmd
# Query one entity by id
.\query-ha.cmd -EntityId climate.master_bedroom_thermostat
# Query an arbitrary HA REST path
.\query-ha.cmd -Path /api/config
# Emit raw JSON
.\query-ha.cmd -EntityId climate.master_bedroom_thermostat -RawJsonIf you want to override the local defaults for one call, pass -BaseUrl and -TokenPath, or set HA_BASE_URL, HA_TOKEN, or HA_TOKEN_PATH in your shell.
# Watch all topics
mosquitto_sub -h your-broker -t "tuya/#" -vYou should see state topics appearing as the device reports its data points.
In the current implementation, the topic root uses the device's configured name sanitized for MQTT, not the Tuya device ID. For example, "Master Bedroom Heat Pump" becomes master_bedroom_heat_pump, and availability is published to tuya/{device_name}/bridge_status.
tuya/{device_name}/bridge_status → "online" or "offline"
tuya/{device_name}/state/{dp_code} → current value
tuya/{device_name}/command/{dp_code} → publish here to send commands
Example state topics:
tuya/master_bedroom_heat_pump/state/switch → "true"
tuya/master_bedroom_heat_pump/state/temp_set_f → "72"
tuya/master_bedroom_heat_pump/state/temp_current_f → "69"
tuya/master_bedroom_heat_pump/state/mode → "heat"
tuya/master_bedroom_heat_pump/state/fan_speed_enum → "auto"
tuya/master_bedroom_heat_pump/state/work_status → "heating"
tuya/master_bedroom_heat_pump/state/solar_power → "847"
tuya/master_bedroom_heat_pump/state/grid_power → "312"
tuya/master_bedroom_heat_pump/state/solar_percent → "73"
tuya/master_bedroom_heat_pump/state/total_energy → "1640523"
Example commands:
# Turn off via mode (Home Assistant style)
mosquitto_pub -t "tuya/master_bedroom_heat_pump/command/mode" -m "off"
# Set to cooling mode (also turns unit on)
mosquitto_pub -t "tuya/master_bedroom_heat_pump/command/mode" -m "cool"
# Set temperature (accepts integers or floats)
mosquitto_pub -t "tuya/master_bedroom_heat_pump/command/temp_set_f" -m "72"
# Set fan speed
mosquitto_pub -t "tuya/master_bedroom_heat_pump/command/fan_speed_enum" -m "medium"The bridge automatically converts between Home Assistant's HVAC values and Tuya's device values:
| DP | HA Value | Tuya Value |
|---|---|---|
| mode | off |
switch = false |
| mode | cool |
cold + switch = true |
| mode | heat |
hot + switch = true |
| mode | fan_only |
wind + switch = true |
| mode | auto |
auto + switch = true |
| fan_speed_enum | medium |
mid |
Setting mode to anything other than "off" automatically turns the unit on. Setting mode to "off" turns the unit off via the switch DP. State topics publish HA-compatible values (e.g., state/mode reports "cool" not "cold").
With HA_DISCOVERY_ENABLED=true, the bridge publishes retained Home Assistant MQTT discovery topics under your discovery prefix. It uses standard per-entity discovery topics such as:
homeassistant/climate/{device_name}/config
homeassistant/binary_sensor/{device_name}_heat/config
homeassistant/light/{device_name}_light/config
Each discovered entity includes the same device metadata, so Home Assistant groups them under a single heat pump device. The bridge currently auto-discovers:
- one
climateentity - one
lightentity for the display light - one
binary_sensorforHeating Active
Additional sensors can be auto-discovered when those DPs are mapped by code name in your device config.
Operational note: Avoid manually probing Tuya LAN port
6668with tools likenc,telnet, or ad hoc socket tests. On this device family, repeated manual probes can cause the module to refuse further local connections until the unit is power-cycled.
The bridge now supports dual logging:
docker logsfor quick container inspection- Persistent file logs under
./logs/for long-lived troubleshooting
Relevant environment variables:
RUST_LOG=info
LOG_STDOUT_ENABLED=true
LOG_FILE_ENABLED=true
LOG_DIR=/app/logs
LOG_ROTATION=daily
LOG_FILE_PREFIX=tuya-to-mqtt.log
LOG_ROTATION=daily creates date-based files in the mounted log directory. Set LOG_ROTATION=never if you prefer one continuously growing file.
| DP | Code | Type | Values | Description |
|---|---|---|---|---|
| 1 | switch |
Boolean | true/false | Power on/off |
| 2 | temp_set |
Integer | 16-32 | Target temperature (C) |
| 3 | temp_current |
Integer | -20 to 100 | Current temperature (C, signed) |
| 4 | mode |
Enum | off, auto, cool, heat, fan_only | HVAC mode (HA-compatible) |
| 6 | mode_eco |
Boolean | true/false | Eco mode |
| 9 | anion |
Boolean | true/false | Ionizer |
| 10 | heat |
Boolean | true/false | Auxiliary/compressor heat active |
| 11 | light |
Boolean | true/false | Display LED on/off |
| 19 | temp_set_f |
Integer | 61-90 | Target temperature (F) |
| 20 | temp_current_f |
Integer | -4 to 212 | Current temperature (F) |
| 21 | temp_unit_convert |
Enum | c, f | Temperature unit |
| 22 | work_status |
Enum | off, cooling, heating, ventilation | Operating status |
| 23 | fan_speed_enum |
Enum | auto, low, medium, high | Fan speed (HA-compatible) |
| 24 | fault |
Bitmap | sensor_fault, temp_fault | Fault flags |
| 101 | sleep |
Boolean | true/false | Sleep mode |
| DP | Code | Type | Unit | Description |
|---|---|---|---|---|
| 106 | solar_power |
Integer | W | Real-time solar power input |
| 107 | solar_energy |
Integer | Wh | Lifetime solar energy counter |
| 108 | solar_percent |
Integer | % | Solar percentage of total power |
| 109 | grid_percent |
Integer | % | Grid percentage of total power |
| 110 | total_energy |
Integer | Wh | Lifetime total energy counter |
| 111 | grid_power |
Integer | W | Real-time grid power draw |
Note: The unofficial DPs may or may not be accessible through the Tuya local protocol (they were discovered on the UART bus). The bridge will attempt to query them — if the device responds, they'll appear on MQTT. If not, you'll see a log message and the official DPs will still work.
# Debug build
cargo build
# Release build (optimized)
cargo build --release
# Docker build
docker compose buildThe Docker image supports cross-compilation for linux/amd64, linux/arm64, and linux/arm/v7.
.
├── src/
│ ├── main.rs # Task orchestration, command routing, shutdown
│ ├── config.rs # Loads devices.json + environment variables
│ ├── mqtt/
│ │ ├── mod.rs
│ │ └── client.rs # MQTT client with LWT and change detection
│ └── tuya/
│ ├── mod.rs # DpUpdate and DpCommand types
│ └── client.rs # Tuya local protocol client with reconnection
├── reverse-engineering/ # UART captures, analysis scripts, protocol docs
│ ├── PROTOCOL.md # Complete protocol specification (25 DPs)
│ ├── uart_sniffer.py # Dual UART capture tool (Raspberry Pi)
│ ├── analyze_capture.py # Packet decoder and analysis
│ ├── deep_dp_analysis.py # Burst packet handling and DP extraction
│ ├── verify_power_model.py # Mathematical verification of power metrics
│ ├── ble_uart_correlate.py # BLE + UART correlation scanner
│ └── uart_capture_*.json # Raw capture data
├── devices.json.example # Config template with full DP mapping
├── .env.example # Environment variable template
├── Dockerfile # Multi-stage build with cargo-chef
└── docker-compose.yml
The WBR3 module communicates with the heat pump's MCU via a serial UART connection at 9600 baud using Tuya's proprietary MCU protocol. We tapped both TX and RX lines using a Raspberry Pi 3B with two serial ports:
/dev/ttyAMA0(PL011) — tapped the Tuya module's TX line (Tuya -> MCU)/dev/ttyUSB0(USB-Serial adapter) — tapped the MCU's TX line (MCU -> Tuya)
Over 3 capture sessions totaling ~97 minutes, we decoded approximately 4,063 packets with a 100% checksum verification rate. The captures covered:
- Steady heating with active solar, power cycling, mode cycling
- All 5 HVAC modes, all fan speeds, vane control, light toggle, a full power cycle/init sequence
- Extended heating as solar declined to zero, C/F unit toggle, swing disable
Every packet follows Tuya's frame format: 55 AA [version] [command] [length] [data] [checksum], where version 0x00 = from MCU and 0x03 = from WiFi module.
Discovering Hidden Data Points
Tuya's cloud API exposes 16 official data points for this device (power, mode, temperature, fan speed, etc.). But through UART analysis, we discovered 25 total DPs — including a set of solar/energy DPs that Tuya never documents:
| DP | Name | What It Does |
|---|---|---|
| 106 | Solar Power | Real-time solar input in watts |
| 111 | Grid Power | Real-time grid draw in watts |
| 108 | Solar % | Percentage of power from solar |
| 109 | Grid % | Percentage of power from grid |
| 107 | Solar Energy | Cumulative solar energy in Wh (lifetime counter) |
| 110 | Total Energy | Cumulative total energy in Wh (lifetime counter) |
| 105 | Vertical Swing | Vane oscillation enable/disable |
| 112 | Vane Step | Pulse signal that moves the vane one position |
| 119 | Transition Timer | Compressor protection timer during mode changes |
These DPs are pushed from the Tuya module to the MCU via Tuya's non-standard CMD 0x22 (record-type DP report), updating every ~3 seconds during operation.
This was one of the most surprising findings. The WBR3 module has no sensors — only 5 pins (VCC, GND, Enable, TX, RX). Yet it pushes real-time solar and grid power data to the MCU. The data comes from BLE sub-devices inside the heat pump.
We confirmed this by running a simultaneous BLE scan + UART capture from the Raspberry Pi. We found 4 Tuya BLE devices near the unit:
- The WBR3 itself (acting as a BLE gateway)
- 3 BLE sub-devices (power monitoring modules)
The WBR3 connects to these sub-devices via encrypted BLE GATT (service UUID 1910), reads their sensor data, and relays it to the MCU over UART. The BLE advertisements are static — the actual data flows over encrypted GATT connections, not advertisements.
EG4 markets this unit as a "solar hybrid" heat pump. We mathematically verified their power tracking across 648+ data points from all 3 captures:
Total Power (W) = DP106 (Solar W) + DP111 (Grid W)
DP108 (Solar %) = round(DP106 / Total * 100)
DP109 (Grid %) = round(DP111 / Total * 100)
DP108 + DP109 = 100 (always)
Results:
- Average calculation error: 1.0-2.3% (caused by sequential DP timing, not model inaccuracy)
- Solar and grid percentages always sum to exactly 100%
- Energy counters are monotonically increasing and consistent across power cycles
- Lifetime readings during capture: ~1,233 kWh solar / ~1,640 kWh total = 75.2% solar fraction
The energy tracking is real and mathematically sound. The small errors we observed are from DPs updating sequentially (not atomically) — by the time you read DP108 (solar %), DP106 (solar W) might have already changed slightly.
- Temperature DP3 is signed — it can go negative for sub-zero Celsius readings. Parse as
int32, notuint32. - Outdoor temperature is NOT on the UART — the outdoor sensor connects directly to the outdoor unit's control board.
- The MCU sends burst packets — during mode changes, multiple DPs are concatenated in rapid-fire CMD 0x06 packets.
- CMD 0x07 has a dual role — it serves as both acknowledgment (echoing MCU reports) and control (delivering commands from app/cloud).
Full protocol documentation is in reverse-engineering/PROTOCOL.md.
- tinytuya — Python library for Tuya local protocol and credential extraction
- rust-async-tuyapi — Rust async Tuya local protocol client
- rumqttc — Rust MQTT client
This project is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0). See the LICENSE file for full terms.