Reads your LuxPower hybrid inverter through the LuxCloud cloud API — the same data the phone app shows, including the parts a local Modbus connection cannot see: dongle health, localised fault history, BMS cell detail, the firmware catalogue, the intraday power curve, yearly totals and the HR[179] configuration bits. It can also write the handful of settings only the cloud exposes.
36 entities, 2 devices. 29 read-only entities, plus 3
switchentities for the HR[179] configuration bits a local Modbus integration does not expose, 4buttons for quick charge/discharge, and aluxcloud_ha.set_bitservice. See Writing to the inverter.
Both paths reach the same inverter, but they carry different data. This integration does not replace a local integration — it fills the gaps the local one has no access to.
| Data | Local Modbus (e.g. lxp_modbus) |
This integration (cloud) |
|---|---|---|
| Live power / voltage / SOC | ✅ real time | ✅ but delayed by the dongle upload interval |
| Full register read/write, settings | ✅ | ⛔ only what the app exposes |
| Dongle connectivity + last cloud report | ⛔ | ✅ lost, lastUpdateTime |
| Fault history with localised text | ⛔ | ✅ (server returns text in your app language) |
| Firmware catalogue + version check | ⛔ | ✅ per device type |
| Intraday power curve, yearly totals | ⛔ | ✅ |
| BMS cell min/max voltage & temperature, cycles | partial | ✅ |
| Works when the inverter is on another network | ⛔ | ✅ |
Local network access to the inverter is not required. Only an internet connection and a LuxPower cloud account are.
- 36 entities across 2 devices — one device per inverter, plus the datalogger as a
separate device linked with
via_device. - Config flow with validation — credentials and serial are verified during setup, plus a reauth step and an options step (no YAML at all).
- Adjustable polling — 60 s to 3600 s (default 300 s).
- Slow-key scheduling — firmware catalogue, intraday curve and yearly totals are fetched every 6th poll (~30 min), so the fast path stays cheap.
- Writes only what the cloud owns — 3
switchentities plus quick charge/dischargebuttons and aset_bitservice. Writes are serialised, so two commands fired at the same moment reach the cloud in the order you asked for them. See Writing to the inverter. - Diagnostics support — download a config dump from the UI with the account, password,
serial numbers and plant name replaced by
**REDACTED**, so the file is safe to attach to an issue. Raw readings, bit states and firmware codes are kept, because that is what makes the dump useful. It also reports the coordinator state — when the last successful poll was, the last error, and how many polls have failed in a row — which tells a flaky cloud apart from a broken setup without digging through logs. - No third-party dependencies — stdlib plus Home Assistant itself (
requirements: []). - Real fault text — the cloud returns already-localised fault strings (Vietnamese here), no client-side translation table to maintain.
| Requirement | Details |
|---|---|
| Home Assistant | 2026.9 or newer (verified on 2026.9.3 and, after the automatic update, on 2026.9.4) |
| Python | 3.14 (shipped with that HA release) |
| LuxPower account | The account you use in the LuxCloud app |
| Inverter | LuxPower hybrid with a WiFi/LAN datalogger that is online |
- HACS → ⋮ → Custom repositories
- Add
https://github.com/ngoviet/luxcloud_hawith category Integration - Search for LuxCloud, install it
- Restart Home Assistant
Copy custom_components/luxcloud_ha/ into your Home Assistant config/custom_components/
directory and restart.
Settings → Devices & Services → Add Integration → LuxCloud
| Field | Notes |
|---|---|
| Account | The e-mail you sign in to the LuxCloud app with |
| Password | Your LuxPower account password |
| Inverter serial number | e.g. 61204F0266; also visible in the app under Device → Info |
| Region | vn (default), as, na, eu2, ind, af, phi, us — one entry per region |
| Scan interval | Seconds between polls, 60–3600 |
| Enable series | Intraday power curve + yearly totals |
| Enable firmware | Firmware catalogue lookup (always uses the as host) |
Multiple inverters: add the integration once per serial number.
Region matters. The firmware catalogue only answers on the
ashost — regional hosts return zero rows. This is handled internally; the setting is only for the main API.
| Entity | Unit | Notes |
|---|---|---|
sensor.*_chu_ky_pin |
Battery cycle count | |
sensor.*_lech_cell |
mV | Max − min cell voltage |
sensor.*_cell_cao_nhat / _cell_thap_nhat |
mV | Highest / lowest cell voltage |
sensor.*_nhiet_do_cell_cao_nhat / _nhiet_do_cell_thap_nhat |
°C | Cell temperature extremes |
sensor.*_trang_thai_bms |
BMS state (Charging, …) |
|
sensor.*_ngo_ra_ac_pinv |
W | AC output power |
sensor.*_rectifier_prec |
W | Rectifier / AC-charge power |
sensor.*_su_co_gan_nhat |
Latest fault, localised by the server | |
sensor.*_co2_giam / _than_giam / _tuong_duong_cay |
t / kg / trees | Lifetime CO₂, coal and tree equivalents |
sensor.*_ma_firmware_inverter |
Firmware code, e.g. CHAA-000303 |
|
sensor.*_firmware_moi_nhat |
Newest catalogue version for this device type | |
sensor.*_chuoi_cong_suat_hom_nay |
Number of intraday points (curve in attributes) | |
sensor.*_tong_theo_nam |
kWh | Current-year PV total (all years in attributes) |
sensor.*_bit_cau_hinh_hr_179_dang_bat |
Count of enabled HR[179] bits (map in attributes) | |
sensor.*_trang_thai_quick_charge_discharge |
Cloud-side quick charge/discharge state | |
sensor.*_tong_san_luong_plant |
kWh | Plant lifetime yield |
switch.*_grid_peak_shaving |
FUNC_GRID_PEAK_SHAVING — writes (see below) |
|
switch.*_gen_peak_shaving |
FUNC_GEN_PEAK_SHAVING — writes |
|
switch.*_active_power_limit_mode |
FUNC_ACTIVE_POWER_LIMIT_MODE — writes |
|
button.*_quick_charge_start / _quick_charge_stop |
Force battery charging (cloud side) — writes | |
button.*_quick_discharge_start / _quick_discharge_stop |
Force battery discharging (cloud side) — writes | |
binary_sensor.*_su_co_dang_hieu_luc |
A fault is currently active | |
binary_sensor.*_dang_chay_quick_charge_discharge |
A quick charge/discharge task is running | |
binary_sensor.*_co_firmware_moi |
Newer firmware available than installed | |
binary_sensor.*_dang_chay_khong_luoi_eps |
Running off-grid — derived from runtime.status & 0xC0 (bits 0x40/0x80), not from the non-existent isOffGrid key (fixed 2026-09-30). On installations that already had the entity, the registry keeps the old entity_id ..._isoffgrid. |
|
binary_sensor.*_cloud_co_du_lieu |
Cloud returned usable runtime data |
| Entity | Notes |
|---|---|
sensor.*_dongle_firmware |
Datalogger firmware, e.g. V2.11 |
sensor.*_dongle_bao_cloud_lan_cuoi |
Timestamp of the last cloud upload |
sensor.*_dongle_kieu_ket_noi |
Datalogger type, e.g. E Wi-Fi |
binary_sensor.*_dongle_mat_ket_noi |
Datalogger offline |
Entity IDs use the device name (
LuxCloud) as a prefix, so ids readsensor.luxcloud_chu_ky_pin. Rename devices in Home Assistant freely — the entity ids follow your rename.
Everything below is a real write — cloud → datalogger → inverter, exactly what the phone app does when you flip one of these in its settings screens. Nothing here is a local register write.
| What | How |
|---|---|
FUNC_GRID_PEAK_SHAVING, FUNC_GEN_PEAK_SHAVING, FUNC_ACTIVE_POWER_LIMIT_MODE |
The 3 switch entities |
| Quick charge / quick discharge | The 4 button entities (start/stop each) |
| Any other HR[179] bit | The luxcloud_ha.set_bit service |
service: luxcloud_ha.set_bit
target:
device_id: 1a2b3c... # optional when only one inverter is configured
data:
function: FUNC_RSD_DISABLE
enable: trueFUNC_GRID_PEAK_SHAVING, FUNC_GEN_PEAK_SHAVING and FUNC_ACTIVE_POWER_LIMIT_MODE belong to
the HR[179] block, which a local Modbus integration does not expose — so there is no second
way to change them.
The other bits this integration can read overlap with settings a local Modbus integration
already owns. Those deliberately have no switch entity, because two integrations writing
the same inverter setting is how you get them fighting each other. Use the set_bit service if
you genuinely need one, and pick a single source of truth for each setting.
Switches report unavailable when the cloud has not returned the bit map, rather than guessing
a state — so a stale off is never mistaken for a real one.
- Unofficial API. Everything here was reverse engineered from the LuxCloud Android app (4.9.8) and verified against a live account. LuxPower can change or restrict it at any time without notice. The AES key used for one refresh call is a protocol constant taken from the app; the integration is not affiliated with or endorsed by LuxPower.
- Cloud only. No local Modbus/RS485. If the vendor cloud cannot be reached, entities go
unavailableinstead of keeping the last reading;binary_sensor.*_dongle_mat_ket_noiand the last-report timestamp tell you how fresh the cloud's data actually is. - Same upload delay as the app. The dongle pushes to the cloud on its own schedule, so a
fast
scan_intervaldoes not make the data younger. - Firmware check is a hint, not an instruction. The catalogue lists the newest package per
device type (e.g.
08) while the inverter reports its ownfwVersion(e.g.3). Cross-check the code (CHAA-000303) in the app before flashing anything — thecaveatattribute records this warning on the entity. - Account sharing. Like the phone app, this integration logs into your cloud account. A session is refreshed transparently; if you see intermittent auth errors while the app is open, increase the scan interval.
- Writes go through the vendor cloud. A write is only as reliable as the cloud→datalogger link: it needs the dongle online, and it takes a few seconds to show up in the reading. A rejected write raises an error in Home Assistant instead of silently pretending it worked.
- Writing is only tested on one inverter model. The read path is verified broadly; the
write endpoints (
remoteSet/functionControl) were confirmed on a singleCHAA6.5 kW unit. Treat the first write on a different model as something to watch.
| Symptom | Check |
|---|---|
| Cannot connect during setup | Region (try the one your app uses), then confirm the inverter shows online in the app |
| Invalid auth | Password is case sensitive; Google/Apple sign-ins need a password set first |
| Entities unavailable | The dongle is offline — power, network or unplugged |
| Values frozen | Compare binary_sensor.*_dongle_mat_ket_noi and the last cloud report timestamp |
| No firmware found | Device type is derived from the firmware code prefix; open an issue with your fwCode |
| Phase | Content | State |
|---|---|---|
| 1 | Read-only: health, faults, BMS, power, energy, firmware, day curve, totals, config bits | ✅ shipped as v1.0.0 |
| 2 | switch for HR[179] config bits, buttons for quick charge/discharge, set_bit service |
✅ shipped as v1.1.0 |
| 2.1 | Hardening: writes serialised so two commands cannot interleave, coordinator state in diagnostics, issue templates | ✅ shipped as v1.2.0 |
| 2.2 | off_grid flag corrected: derive it from runtime.status & 0xC0 — the isOffGrid key the old code read does not exist in the API, so the flag was stuck off even during a real outage |
✅ shipped as v1.2.1 |
| 3 | Options polish, more translations | ⏳ planned |
Submitting to the HACS default store is deliberately not planned: this integration talks to an unofficial, reverse-engineered cloud API, so a default-store listing would imply a stability promise it cannot keep, and it is a companion to a local Modbus integration rather than a replacement, so its audience is narrower than a default listing suggests. This costs you nothing — installing and updating it as a HACS custom repository is the same code path.
Writes stay deliberately narrow: only the settings the cloud owns, and only bits a local Modbus integration does not already control.
Settings → Devices & Services → LuxCloud → ⋮ → Delete. Entities and both devices are removed; your account and the inverter settings are untouched.
All tooling runs from the repository root and needs no third-party packages beyond
paramiko (SSH deploy) and Node 22+ (WebSocket helper):
| Command | Purpose |
|---|---|
python tools/deploy_to_ha.py --restart |
push custom_components/luxcloud_ha/ to a Home Assistant host over SSH, verify every file by md5, then restart and wait for it to come back |
python tools/verify_live.py |
acceptance check: config entry, all 36 entities, the inverter↔dongle device link, and the count of deprecation/error lines after the last boot only |
python tools/hass.py cfgcheck |
run check_config inside the Home Assistant container |
python tools/make_brand.py |
regenerate brand/{icon,icon@2x,logo}.png with the standard library |
python tools/setup_tests.py --run |
build the test virtualenv and run the suite |
tests/ holds 265 tests. Besides the read path (cloud-response normalisation — mV, ×10,
0.1 kWh, the "False" string booleans — every entity's value function, the coordinator's
slow-key cache, the config/reauth flows, and a full setup asserting every entity id and the
dongle→inverter device link), the write path is covered by pressing the actual Home
Assistant services (switch.turn_on, button.press, luxcloud_ha.set_bit) and asserting
both the command sent to the cloud and the resulting entity state — including rejected writes,
bad bit names, and the refusal to guess which inverter to write to when several are configured.
Three areas have their own files because they are easy to get subtly wrong:
test_diagnostics.pyserialises the diagnostics payload and scans it for the password, account e-mail, serial numbers and plant name, so a future field cannot quietly leak them. It also asserts the dump still carries the readings needed to debug.test_options_flow.pydrives the real options flow and checks the entry actually reloads and the coordinator picks up the new interval — not just that the schema accepts a number.test_availability.pychecks that entities gounavailablewhen the cloud fails (rather than silently keeping the last reading), recover afterwards, and that an auth failure starts a reauth flow.
The manifest and the config/service strings are verified through Home Assistant's own loader
and translation helper — including the bronze config-flow rule that every field carries a
data_description, checked against the flow's real schema rather than a hardcoded list.
python tools/setup_tests.py --run # builds .venv, installs deps, runs pytestrequirements_test.txt pins pytest-homeassistant-custom-component, which pins
homeassistant to an exact version (0.13.367 → 2026.9.4) — bump that one line to move
the suite to a new HA release.
Windows. Home Assistant's test harness imports the POSIX-only
fcntlandresourcemodules, and blocks sockets in a way Windows' asyncio event loop cannot work around.tools/setup_tests.pydrops small stubs into.venv(fromtools/win32_stubs/) andtests/conftest.pyrelaxes the socket guard — both only when running on Windows. On Linux nothing is relaxed and the tests still cannot open a real socket.
Copy .env.example to .env and fill in the host, SSH password and a long-lived access
token. .env is git-ignored.
Two Home Assistant deprecations are already handled here and will stop working in 2027.8
if they regress: via_device_id instead of via_device, and async_get_device_id_by_identifier
instead of DeviceRegistry.async_get_device.
manifest.json declares quality_scale: bronze, and the Bronze rules were
audited against the code rather than assumed:
| Rule | Where |
|---|---|
action-setup |
service registered in async_setup |
appropriate-polling |
scan interval configurable 60–3600 s |
brands |
custom_components/luxcloud_ha/brand/ |
config-flow (+ data_description) |
every field described; enforced by a test against the flow's real schema |
config-flow-test-coverage |
tests/test_config_flow.py |
dependency-transparency |
requirements: [] — stdlib plus HA only |
docs-* |
this README (actions, description, install, removal, limitations) |
entity-unique-id, has-entity-name |
asserted in tests/test_setup.py |
runtime-data |
entry.runtime_data |
test-before-configure, test-before-setup |
login probed in the config flow; ConfigEntryNotReady/ConfigEntryAuthFailed on setup |
unique-config-entry |
async_set_unique_id + _abort_if_unique_id_configured |
docs-triggers and docs-conditions do not apply — this integration registers neither.
Home Assistant reports quality_scale: custom for any non-core integration, so the manifest
value is documentation for readers and HACS, not something HA enforces.
MIT © 2026 ngoviet