lg-therma-v-modbus
Read and control LG Therma V heat pumps over Modbus, as typed Python objects rather than register numbers. The Eastron SDM120 energy meter that is often wired onto the same RS-485 line is included.
The library maps the registers onto
modbus-connection's device model:
you hand it a ModbusUnit, call async_update(), and read sub-systems as
attributes. It owns no connection and no I/O policy — the caller does.
Supported devices
ThermaV — the heat pump. Every register LG's Modbus register list
documents: 17 discrete inputs, 6 coils, input registers 30001 to 30025 and 30037
to 30046, holding registers 40001 to 40010 and 40024, and the two product
registers 39998 and 39999. They are split into sub-systems that are read
separately, so a block a product refuses cannot take the others with it.
| Sub-system | Space | Content |
|---|---|---|
status |
discrete inputs | compressor, defrosting, pumps, heaters, error and availability bits |
readings |
input registers | water, room air, DHW tank, solar collector and outside air temperatures, flow rate, error code, operation cycle, energy state |
water_pressure |
input register | the water circuit's pressure |
refrigerant_cycle |
input registers | refrigerant temperatures and pressures, compressor frequency |
controls |
coils (writable) | heating/cooling and DHW on/off, silent mode, emergency stop, and the triggers for DHW disinfection and emergency operation |
settings |
holding registers (writable) | operation mode, control method, target temperatures of both circuits and of the DHW tank, room air inputs, auto-mode shifts, energy state input |
product |
input registers | product group and type, read once with async_read_product() |
energy_counters |
input registers | the heat pump's own energy measurement, December 2023 or later |
compressor_modulation |
holding register (writable) | limit on the compressor, December 2023 or later |
Two heating circuits exist. Each has a target temperature, a room air temperature and an auto-mode shift. The operation mode and the control method (which sensor the target is held against) apply to both.
Products from December 2023
Products from before December 2023 do not have the energy counters (input registers 30037 to 30046) and the compressor modulation (holding register 40024), and refuse a read of them. They are therefore left alone unless you say the product is newer:
heat_pump = ThermaV(unit, from_december_2023=True)
Without it energy_counters and compressor_modulation are None, and a poll
never asks for their registers. With it they are polled like the rest; a
product that turns out not to have them lists them in the report's failed.
The compressor modulation is 0 (not used) or 40 to 100. Any other value
raises ValueError before anything is written.
Sdm120 — the meter. An Eastron SDM120 single-phase energy meter, read
over its input registers as IEEE-754 floats: voltage (V), active_power (W)
and total_energy (kWh). It is a different unit on the same line, so it takes
its own ModbusUnit.
Usage
import asyncio
from modbus_connection import ModbusSerialParams
from modbus_connection.tmodbus import ModbusConnection
from lg_therma_v_modbus import OperationMode, Sdm120, ThermaV
async def main() -> None:
connection = ModbusConnection(ModbusSerialParams(device="/dev/ttyUSB0"))
try:
heat_pump = ThermaV(connection.for_unit(1))
meter = Sdm120(connection.for_unit(2))
await heat_pump.async_update()
await meter.async_update()
print("Outside:", heat_pump.readings.outside_air_temperature, "°C")
print("Flow:", heat_pump.readings.flow_rate, "l/min")
print("Compressor running:", heat_pump.status.compressor)
print("Mode:", heat_pump.settings.operation_mode)
print("Pressure:", heat_pump.water_pressure.water_pressure, "bar")
print("Power:", meter.readings.active_power, "W")
await heat_pump.settings.write("dhw_target_temperature", 48.0)
await heat_pump.settings.write("operation_mode", OperationMode.AUTO)
await heat_pump.controls.write("silent_mode", True)
finally:
await connection.close()
asyncio.run(main())
A poll reads each sub-system independently: one slow or refused block does not
take the rest of the poll with it. Every update method returns an
UpdateReport — a failed component keeps its previous values, does not notify
its listeners, and is listed by attribute name with its error, while every
other component refreshes and notifies once the whole poll is done. A dead
link (ModbusConnectionError) raises, and so does a timeout before any
component has answered.
report = await heat_pump.async_update()
for name, error in report.failed.items():
print(f"{name} kept its previous values: {error}")
ThermaV splits its poll by what it reads:
async_update_readings()— what the heat pump reports:status,readings,water_pressure,refrigerant_cycleand, if enabled,energy_counters.async_update_settings()— what it has been told:controls,settingsand, if enabled,compressor_modulation. Run one after a write to read back what took effect.async_update()— both, in one merged report.
Writing is by field name through the component: await heat_pump.controls.write( "silent_mode", True). Only the fields of controls, settings and
compressor_modulation are writable; writing a reading raises AttributeError.
trigger_dhw_disinfection and trigger_emergency_operation are one-shot
triggers: write True to start the operation. The heat pump keeps its status
when they are cleared, so they are not states to read back; status reports
what is running (dhw_tank_disinfection, emergency_operation_available_*).
How the Therma V wants to be talked to
The heat pump is picky about its Modbus master. Four things matter.
- It answers from the third request. On a fresh link its first requests
go unanswered, and they must be aimed at an input register or a coil. The
first poll of a
ThermaVtherefore starts with three reads of input register 0. An unanswered one is expected and does not fail the poll; a dead link still does. This happens once per object, so build a newThermaVafter a reconnect that should warm up again. - Never stop polling. After five minutes without a request the heat pump
shows the error
CH 03, and only cutting its power clears it. A caller that starts polling has to keep going. A bridge that polls on its own behalf keeps the heat pump out of that state when the caller is down. - Poll at a moderate pace. Neither very fast nor very slow polling stays
error-free for long. A request spacing of about 100 ms suits a USB adapter
and about 30 ms a network bridge; set it on the unit with
unit.set_message_spacing(). - Do not read large ranges. The library reads each sub-system as a single small block (at most 17 addresses), which is what the heat pump tolerates. It never reads across the registers LG leaves blank (30015, 30016).
The unit ID is the one set on the heat pump's control panel under Connectivity > Modbus address.
Naming the link
How to name the link depends on what sits between you and the RS-485 line, not on the heat pump:
| Between you and the line | Parameters |
|---|---|
| Nothing: a USB or serial adapter | ModbusSerialParams(device="/dev/ttyUSB0") |
| A box forwarding the RTU frames | ModbusSerialParams(device="socket://host:port") |
| A gateway answering Modbus TCP | ModbusTcpParams(host=...) |
The heat pump speaks 9600 baud, 8 data bits, no parity and one stop bit, which
are the defaults of ModbusSerialParams. ASCII framing is unsupported.
Register reference
Register numbers are LG's; the Modbus address is the number without its leading digit, minus one (30003 is input address 2). LG's installation manual labels the input and holding registers the wrong way round; the tables below are the corrected ones, and what the heat pump answers.
Coils (writable)
| Register | Attribute | LG's name |
|---|---|---|
| 00001 | heating_cooling_enabled |
Enable/Disable (Heating/Cooling) |
| 00002 | dhw_enabled |
Enable/Disable (DHW) |
| 00003 | silent_mode |
Silent Mode Set |
| 00004 | trigger_dhw_disinfection |
Trigger Disinfection operation |
| 00005 | emergency_stop |
Emergency Stop |
| 00006 | trigger_emergency_operation |
Trigger Emergency Operation |
Discrete inputs (status)
| Register | Attribute | LG's name |
|---|---|---|
| 10001 | water_flow |
Water flow status (documented as 0 flow rate ok, 1 too low; observed: 1 while water flows, 0 when the pump stops) |
| 10002 | water_pump |
Water Pump status |
| 10003 | external_water_pump |
Ext. Water Pump status |
| 10004 | compressor |
Compressor status |
| 10005 | defrosting |
Defrosting status |
| 10006 | dhw_heating |
DHW heating status (DHW Thermal On/Off) |
| 10007 | dhw_tank_disinfection |
DHW Tank disinfection status |
| 10008 | silent_mode |
Silent mode status |
| 10009 | cooling |
Cooling status |
| 10010 | solar_pump |
Solar pump status |
| 10011 | backup_heater_step_1 |
Backup heater (Step 1) status |
| 10012 | backup_heater_step_2 |
Backup heater (Step 2) status |
| 10013 | dhw_boost_heater |
DHW boost heater status |
| 10014 | error |
Error status |
| 10015 | emergency_operation_available_space |
Emergency Operation Available (Space heating/cooling) |
| 10016 | emergency_operation_available_dhw |
Emergency Operation Available (DHW) |
| 10017 | mix_pump |
Mix pump status |
Input registers
| Register | Attribute | LG's name | Scale |
|---|---|---|---|
| 30001 | readings.error_code |
Error Code | |
| 30002 | readings.operation_cycle |
ODU operation Cycle (0 standby, 1 cooling, 2 heating) | |
| 30003 | readings.water_inlet_temperature |
Water inlet temp. | 0.1 °C |
| 30004 | readings.water_outlet_temperature |
Water outlet temp. | 0.1 °C |
| 30005 | readings.backup_heater_outlet_temperature |
Backup heater outlet temp. | 0.1 °C |
| 30006 | readings.dhw_tank_temperature |
DHW tank water temp. | 0.1 °C |
| 30007 | readings.solar_collector_temperature |
Solar collector temp. | 0.1 °C |
| 30008 | readings.circuit_1_room_air_temperature |
Room air temp. (Circuit 1) | 0.1 °C |
| 30009 | readings.flow_rate |
Current Flow rate | 0.1 l/min |
| 30010 | readings.circuit_2_flow_temperature |
Flow temp. (Circuit 2) | 0.1 °C |
| 30011 | readings.circuit_2_room_air_temperature |
Room air temp. (Circuit 2) | 0.1 °C |
| 30012 | readings.energy_state |
Energy State input | |
| 30013 | readings.outside_air_temperature |
Outdoor Air temp. | 0.1 °C |
| 30014 | water_pressure.water_pressure |
Water pressure | 0.1 bar |
| 30017 | refrigerant_cycle.pipe_in_temperature |
Pipe in temp | 0.1 °C |
| 30018 | refrigerant_cycle.pipe_out_temperature |
Pipe out temp | 0.1 °C |
| 30019 | refrigerant_cycle.suction_temperature |
Suction temp | 0.1 °C |
| 30020 | refrigerant_cycle.discharge_temperature |
INV1 dis. temp | 0.1 °C |
| 30021 | refrigerant_cycle.heat_exchanger_temperature |
HEX temp | 0.1 °C |
| 30022 | refrigerant_cycle.evaporator_outlet_temperature |
(not named by LG) | 0.1 °C |
| 30023 | refrigerant_cycle.high_pressure |
HighPress | 0.01 bar |
| 30024 | refrigerant_cycle.low_pressure |
LowPress | 0.01 bar |
| 30025 | refrigerant_cycle.compressor_frequency |
Inverter [Hz] | Hz |
| 30037–30038 | energy_counters.energy_instantaneous_total |
ENERGY_INSTANTANEOUS_TOTAL | Wh |
| 30039–30040 | energy_counters.energy_accumulative_total |
ENERGY_ACCUMULATIVE_TOTAL | Wh |
| 30041–30042 | energy_counters.energy_instantaneous_heat |
ENERGY_INSTANTANEOUS_HEAT | Wh |
| 30043–30044 | energy_counters.energy_accumulative_cool |
ENERGY_ACCUMULATIVE_COOL | Wh |
| 30045–30046 | energy_counters.energy_accumulative_dhw |
ENERGY_ACCUMULATIVE_DHW | Wh |
| 39998 | product.product_group |
Product Group (0x80, 0x83, 0x88, 0x89) | |
| 39999 | product.product_type |
Product Info (0 split, 3 monobloc, 4 high temp., 5 medium temp., 6 system boiler) |
The registers from 30037 on are from December 2023 or later, except 39998 and 39999.
Holding registers (writable)
| Register | Attribute | LG's name |
|---|---|---|
| 40001 | settings.operation_mode |
Operation Mode (0 cooling, 3 auto, 4 heating) |
| 40002 | settings.control_method |
Control method (Circuit 1/2): 0 water outlet, 1 water inlet, 2 room air |
| 40003 | settings.circuit_1_target_temperature |
Target temp (Heating/Cooling) Circuit 1, 0.1 °C |
| 40004 | settings.circuit_1_room_air_temperature |
Room Air Temp. Circuit 1, 0.1 °C |
| 40005 | settings.circuit_1_auto_mode_shift |
Shift value (Target) in auto mode Circuit 1, K |
| 40006 | settings.circuit_2_target_temperature |
Target temp (Heating/Cooling) Circuit 2, 0.1 °C |
| 40007 | settings.circuit_2_room_air_temperature |
Room Air Temp. Circuit 2, 0.1 °C |
| 40008 | settings.circuit_2_auto_mode_shift |
Shift value (Target) in auto mode Circuit 2, K |
| 40009 | settings.dhw_target_temperature |
DHW Target temp., 0.1 °C |
| 40010 | settings.energy_state_input |
Energy state input (0 to 8) |
| 40024 | compressor_modulation.compressor_modulation |
Compressor Modulation (0 not used, 40 to 100), December 2023 or later |
The energy states are 0 not used, 1 forced off, 2 normal operation, 3 on recommendation, 4 on command, 5 on command step 2, 6 on recommendation step 1, 7 power saving and 8 super power saving. 1 to 4 are the SG Ready states of the terminal block contacts TB_SG1 and TB_SG2.
Where the sources disagree
The names are LG's own English register list. Where the community documentation differs, this is what was decided:
- Pressures. LG gives 30023 and 30024 in mBar. On a 9 kW monobloc those values only make sense at 0.01 bar per step, so that is the scale. Check it against your own heat pump.
- 30022. LG leaves it unnamed. The community documentation calls it the vapor temperature after the evaporator, which is where the name comes from.
- 30018. The community table omits it; LG lists it as "Pipe out temp".
- Product registers. The community table lists the product information as holding register 49999. LG lists 39998 and 39999 under the input registers, as does a working configuration, so those are the ones read.
- Energy counters. The names are LG's constants, which call two of the values "instantaneous" yet give all of them in Wh. Treat the instantaneous ones with care.
- Not modelled. Input register 30036, which another project reads as the internal power, is in no register list found. Registers 40025 and coil 00007, a power limit, appear in one configuration only and not in any register list.
Checking a real device
script/query.py reads one device once and prints every value it serves,
which is the quickest way to see whether it is reachable and addressed
correctly:
# a USB or serial adapter, heat pump on unit 1
uv run script/query.py /dev/ttyUSB0 --transport serial --unit 1
# the meter on the same line
uv run script/query.py /dev/ttyUSB0 --transport serial --unit 2 --meter
# a box forwarding RTU frames over TCP
uv run script/query.py socket://192.168.1.50:8899 --transport serial --unit 1
# a gateway answering Modbus TCP
uv run script/query.py 192.168.1.60 --port 502 --unit 1
It prints the number of Modbus reads and how long they took. --raw adds every
register read, undecoded, which is what an issue about a wrong value should
quote.
Development
uv sync
uv run ruff format --check .
uv run ruff check .
uv run mypy
uv run pytest
The tests run against modbus-connection's in-memory mock, so no hardware is
needed. See RELEASING.md for publishing a version.
Metadata
Release files for lg-therma-v-modbus 0.1.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 | Provenance | Actions |
|---|---|---|---|---|
| lg_therma_v_modbus-0.1.1.tar.gz | 24.0 kB | With provenance | Details for lg_therma_v_modbus-0.1.1.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| lg_therma_v_modbus-0.1.1-py3-none-any.whl | Python 3 | none | any | Details for lg_therma_v_modbus-0.1.1-py3-none-any.whl |
Total release size: 45.6 kB
Release files / lg_therma_v_modbus-0.1.1.tar.gz
| Download URL | lg_therma_v_modbus-0.1.1.tar.gz |
|---|---|
| Size | 24.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
bc9449196461b7ab5538a6fea052aa3835eb88be7be14ed70f24ea3153d94fa8
|
|
BLAKE2b-256 checksum How to use checksums |
116ada6f1d3a8b9d02aee4bcae06eee9336d0178d5223e6aa488f40c347ad581
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
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 Nov 8, 2026.
Transparency logRelease files / lg_therma_v_modbus-0.1.1-py3-none-any.whl
| Download URL | lg_therma_v_modbus-0.1.1-py3-none-any.whl |
|---|---|
| Size | 21.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
f31911bc29b00e4f5c9cafeba44ac0511777ee8264fc04af9d4f6585f88d5a88
|
|
BLAKE2b-256 checksum How to use checksums |
5d95c88b34c211efca450c409850f2e98c365ad3c531d89f436828dfa36f6607
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
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 Nov 8, 2026.
Transparency logSecurity for lg-therma-v-modbus 0.1.1
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.
Full provenance
Both files in this release have been uploaded with provenance metadata.
Attestation for lg_therma_v_modbus-0.1.1.tar.gz
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 Nov 8, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.