lg-heatpump-modbus Python library
lg-heatpump-modbus is an asynchronous, transport-independent Python library
for reading and controlling LG heat pumps — the Therma V family and the
siblings that share its Modbus register map — over Modbus.
DISCLAIMER
This library is in early development. It is not yet feature-complete, bugs are expected!
Anyone with a real device is encouraged to test it and report issues. See section Contributing for a quick way to check your installation.
Purpose and scope
The library is meant for operational monitoring and day-to-day control of a commissioned installation: temperatures, pressures, pump and heater states, setpoints, operating mode, hot water and silent mode. It is not a commissioning tool and does not attempt to reproduce every installer menu.
The library:
-
contains the device-specific data model — the valid registers and coils, their data types, scaling, units, ranges and enumerations, and the rules for safe reads and writes;
-
structures the register map as device models, so a new variant is added as a
ModelDefinition(data) rather than as new code; -
carries neutral datapoint metadata — unit, precision, step, limits, enum options and writability — next to each address, so the model is the datasheet;
-
does not create or own the Modbus transport. Applications provide a
modbus_connection.ModbusUnitand may use any backendmodbus-connectionsupports (tmodbus, pymodbus, …).
Prerequisites
Development or testing this library can be done without a real heat pump, using the in-memory mock backend that ships with modbus-connection. The mock backend simulates a heat pump and responds to reads and writes.
Before you can use the library with a real heatpump, you must have a working Modbus connection to the heat pump.
This takes two steps:
- Get and connect a compatible Modbus interface to the heat pump. At this point, the Waveshare RS485 to ETH Adapter is known to work and well-tested.
- Configure the heat pump to enable Modbus communication.
Both steps are explained in more detail here, you can skip the middle Homeassistant section.
Installation
pip install lg-heatpump-modbus
The library itself only needs the protocol and the device-modelling framework. To use the bundled query script, install a concrete backend as well:
pip install "lg-heatpump-modbus[cli]"
Usage
import asyncio
from modbus_connection import ModbusTcpParams
from modbus_connection.tmodbus import ModbusConnection
from lg_heatpump_modbus import LgHeatPump, OperationMode
async def main() -> None:
connection = ModbusConnection(ModbusTcpParams(host="192.168.1.50", port=502))
try:
pump = LgHeatPump(connection.for_unit(1))
await pump.async_update()
print("Outdoor:", pump.sensors.outdoor_temperature, "°C")
print("Flow:", pump.sensors.water_outlet_temperature, "°C")
print("Compressor running:", pump.states.compressor)
print("Circuit 1 target:", pump.controls.target_temperature_circuit_1)
await pump.controls.set_operation_mode(OperationMode.HEAT)
await pump.controls.set_target_temperature(1, 42.0)
await pump.switches.set_silent_mode(True)
finally:
await connection.close()
asyncio.run(main())
async_update() fans out to each sub-system, and each sub-system reads only its
own registers in as few Modbus round-trips as the map allows: the whole device
is seven block reads. Poll the two halves at different rates if you prefer:
await pump.async_update_readings() # what the heat pump measures
await pump.async_update_settings() # what it has been configured to do
Every poll returns an UpdateReport naming the sub-systems that refreshed and
those that failed, so one unanswered block does not take the rest with it.
Sub-systems
| Attribute | Modbus table | Contents |
|---|---|---|
pump.info |
input register | Product information word |
pump.sensors |
input registers | Temperatures, pressures, flow rate, compressor frequency, error code, energy state |
pump.states |
discrete inputs | Pump, compressor, heater, defrost and fault flags |
pump.controls |
holding registers | Operation mode, control method, water/room/hot-water setpoints, auto-mode shift |
pump.switches |
coils | Power, hot water, silent mode, disinfection, emergency stop |
Register map
The address range of Modbus is very large and divided into multiple sections called Function Code (FC). Each FC has its own address space at the beginning of the address.
The FCs are either written as hexadecimal (0x01, 0x02, …) or as FC01, FC02, … in the documentation.
Modbus is defined to have four kinds of tables:
| Modbus Table | Data Size | Reading FC | Writing FC |
|---|---|---|---|
| Coil | 1-bit | 0x01 | 0x05 / 0x0F |
| Discrete Input | 1-bit | 0x02 | no, read-only |
| Holding | 16-bit | 0x03 | 0x06 / 0x10 |
| Input | 16-bit | 0x04 | no, read-only |
Addresses used in this library are the actual protocol addresses. The manuals of the manufacturer have an offset of one: Input register 1 of the manufacturer documentation is address 0 here.
The manuals give human-friendly addresses with this scheme: XYYYY
Xis the concerning the address space,X+1equals the function code.YYYYis the address with an offset of+1to the protocol address, so the actual protocol address isYYYY-1.
For instance, when the manual states 10007, this means that:
1: FC02, discrete input0008: Modbus address7(=8-1)
This is the Silent Mode sensor, see states.py:
silent_mode = discrete_input(7, description="Silent mode active")
Input registers (FC04, read-only)
| Address | Datapoint | Scale | Unit |
|---|---|---|---|
| 0 | error_code |
1 | |
| 1 | odu_operation_cycle |
1 | |
| 2 | water_inlet_temperature |
0.1 | °C |
| 3 | water_outlet_temperature |
0.1 | °C |
| 4 | backup_heater_outlet_temperature |
0.1 | °C |
| 5 | dhw_tank_temperature |
0.1 | °C |
| 6 | solar_collector_temperature |
0.1 | °C |
| 7 | room_air_temperature_circuit_1 |
0.1 | °C |
| 8 | water_flow_rate |
0.1 | L/min |
| 9 | water_outlet_temperature_circuit_2 |
0.1 | °C |
| 10 | room_air_temperature_circuit_2 |
0.1 | °C |
| 11 | energy_state |
enum | |
| 12 | outdoor_temperature |
0.1 | °C |
| 16 | liquid_pipe_temperature |
1 | °C |
| 18 | suction_temperature |
1 | °C |
| 19 | discharge_temperature |
1 | °C |
| 20 | evaporator_inlet_temperature |
0.1 | °C |
| 21 | evaporator_outlet_temperature |
0.1 | °C |
| 22 | refrigerant_high_pressure |
1 | bar |
| 23 | refrigerant_low_pressure |
1 | bar |
| 24 | compressor_frequency |
1 | Hz |
| 9998 | product_info |
raw |
sensors.compressor_speed derives revolutions per minute from
compressor_frequency, and sensors.water_temperature_difference the spread
across the heat exchanger.
solar_collector_temperature reads as None when no solar collector is
fitted: the heat pump reports a fixed dummy value of 300.0 °C instead of
refusing the read, and that sentinel is masked. Set pump.debug = True to
see the raw 300.0 °C reading instead, e.g. while diagnosing a device.
Holding registers (FC03 read, FC06 write)
| Address | Datapoint | Scale | Unit | Writable |
|---|---|---|---|---|
| 0 | operation_mode |
enum | ✔ | |
| 1 | control_method |
enum | ✔ | |
| 2 | target_temperature_circuit_1 |
0.1 | °C | ✔ |
| 3 | room_air_setpoint_circuit_1 |
0.1 | °C | ✔ |
| 4 | shift_in_auto_mode_circuit_1 |
1 | K | ✔ |
| 5 | target_temperature_circuit_2 |
0.1 | °C | ✔ |
| 6 | room_air_setpoint_circuit_2 |
0.1 | °C | ✔ |
| 7 | shift_in_auto_mode_circuit_2 |
1 | K | ✔ |
| 8 | dhw_target_temperature |
0.1 | °C | ✔ |
| 9 | energy_state |
enum |
Coils (FC01 read, FC05 write)
| Address | Datapoint |
|---|---|
| 0 | heating_circuit |
| 1 | dhw |
| 2 | silent_mode |
| 3 | dhw_disinfection |
| 4 | emergency_stop |
| 5 | trigger_emergency_operation |
Discrete inputs (FC02, read-only)
| Address | Datapoint | Address | Datapoint | |
|---|---|---|---|---|
| 0 | water_flow |
9 | solar_pump |
|
| 1 | water_pump |
10 | backup_heater_step_1 |
|
| 2 | external_water_pump |
11 | backup_heater_step_2 |
|
| 3 | compressor |
12 | dhw_boost_heater |
|
| 4 | defrosting |
13 | error |
|
| 5 | dhw_heating |
14 | emergency_operation_space |
|
| 6 | dhw_disinfection |
15 | emergency_operation_dhw |
|
| 7 | silent_mode |
16 | mixing_pump |
|
| 8 | cooling |
Device models
An installation without a second circuit, a hot water tank or a solar collector should not be asked for registers it does not serve. Name the model and the read plan narrows itself:
from lg_heatpump_modbus import LgHeatPump, THERMA_V_SINGLE_CIRCUIT
pump = LgHeatPump(unit, model=THERMA_V_SINGLE_CIRCUIT)
pump.serves("target_temperature_circuit_2") # False
| Model key | Circuits | Hot water | Solar | Mixing valve |
|---|---|---|---|---|
therma_v |
2 | ✔ | ✔ | ✔ |
therma_v_single_circuit |
1 | ✔ | ||
therma_v_heating_only |
2 | ✔ | ✔ |
Individual datapoints can be excluded on top of the model, for a unit that refuses one particular register:
pump = LgHeatPump(unit, excluded_datapoints=["solar_collector_temperature"])
Adding a variant means adding a ModelDefinition to
lg_heatpump_modbus/configurations/models.py, never a code change.
Datapoint metadata
Each datapoint carries neutral metadata, so an application can build its user interface from the model instead of hard-coding it:
metadata = pump.controls.require_metadata_for("dhw_target_temperature")
metadata.register_type # 'holding'
metadata.address # 8
metadata.writable # True
metadata.number.unit # '°C'
metadata.number.min_value, metadata.number.max_value # 30.0, 80.0
metadata.number.step # 0.1
The documented limits are the protocol limits. The range an installation actually accepts is narrower and set by the installer; a value the heat pump refuses comes back as a Modbus exception.
Writing values
Writes go through the component that owns the register, either by attribute name or through the named helpers:
await pump.controls.write("dhw_target_temperature", 48.0)
await pump.controls.set_dhw_target_temperature(48.0)
await pump.switches.set_heating_circuit(True)
A value outside the documented range raises LgValueValidationError before
anything reaches the wire. Input registers and discrete inputs are read-only
and raise AttributeError if written.
Contributing
Any contribution is welcome. Most valuable is testing against a real device.
1. Prepare your hardware
Following the instructions in Prerequisites, connect a Modbus interface to your heat pump and enable Modbus communication.
2. Set up the library (pre-PyPI stage)
Create a virtual Python environment and install modbus-connection, preferably with tmodbus:
python -m venv venv_modbus
pip install "modbus-connection[tmodbus]"
3a. Run the query script and dump the outputs
Set up the library and run script/query.py against your heat pump. Parameters usually like this:
192.168.0.XXX --port 502 --unit 1 --json-dir C:\tmp\lg_json_dumps
Directly report the JSON output, as well as any unexpected values or errors.
Occasional Response timeouts are expected when the heat pump is still connected to another Modbus device (e.g., legacy Home Assistant integration). If you see a timeout, wait a few seconds and try again.
Add --debug to see raw sentinel values (e.g. solar_collector_temperature's 300 °C no-sensor reading) instead of them being masked to None — handy when a reported value looks suspicious and you want to confirm what the heat pump actually sent.
3b. Use the library from a Python interpreter
Play around with the controls, read the sensors, change controls and switches. Report any findings, unexpected values, or errors. A JSON file from the query script (see section beforehand) is also very valuable for debugging.
Querying a real device
script/query.py connects to a heat pump, reads it once and prints every
value. It is the quickest way to check an installation with no application
around it. Add --json-dir <folder> to write a JSON snapshot for later
analysis; omit it to keep the script purely terminal-based:
python script/query.py 192.168.1.50 --unit 1
python script/query.py /dev/ttyUSB0 --transport serial --unit 1 --baudrate 9600
python script/query.py 192.168.1.50 --unit 1 --json-dir ./query-dumps
python script/query.py --help
After collecting a few JSON snapshots, you can convert them into a single CSV table:
python script/json_to_csv.py --input-dir ./query-dumps --output ./query-dumps.csv
The CSV contains one row per snapshot and one column per flattened datapoint,
with names like components.sensors.outdoor_temperature and
derived.compressor_speed.
Development
script/run_checks.sh # format check, lint, compile, test, build
python -m ruff check --fix .
python -m ruff format .
Tests run against the in-memory mock backend that ships with
modbus-connection, so the whole suite runs without hardware, a server or a
network.
Licence
Apache-2.0. See LICENSE.
Metadata
Release files for lg-heatpump-modbus 0.0.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| lg_heatpump_modbus-0.0.1.tar.gz | 72.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| lg_heatpump_modbus-0.0.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 106.8 kB
Release files / lg_heatpump_modbus-0.0.1.tar.gz
| Download URL | lg_heatpump_modbus-0.0.1.tar.gz |
|---|---|
| Size | 72.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
e022f754e1521dba2600b2bcd5a948a9dfddf2d2d4d3e9a802a2849beb80b2af
|
|
BLAKE2b-256 checksum How to use checksums |
13215b521b84af7178bcbc7efd1c29cd111cfb9b6b6173841b2c1536b0651efc
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 9, 2026.
Transparency logRelease files / lg_heatpump_modbus-0.0.1-py3-none-any.whl
| Download URL | lg_heatpump_modbus-0.0.1-py3-none-any.whl |
|---|---|
| Size | 34.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
5cbaf4b8bfe589704791744432ba61836d493ea969c3d6ada76a46da5fc1e307
|
|
BLAKE2b-256 checksum How to use checksums |
28e3378d75712a572da5523deea6badf1212ed41d187625e351665af133f4631
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 9, 2026.
Transparency log