Status as of September 2026. Compiled from: the hanchu_ess_ble fork's live BLE reads/writes (HA Green + M5Stack Atom Lite BLE proxy), the 1ulk Hanchu BLE Controller (hanchu-params.js / hanchu-controller.js), and cross-referencing against the Hanchu cloud app and a working cloud-based HA integration.
Scope note: this document maps every code investigated, including many not exposed as entities by default. The integration's built entities mirror the common cloud integration entity list. Everything else here is a reference for anyone wanting to expose additional codes using the same async_read_values() / async_write_value() pattern.
- Transport: BLE GATT. Service UUID
0000ffff-0000-1000-8000-00805f9b34fb(fallback0000ff00-...). Notify characteristic0000ff01-..., write characteristic0000ff02-.... - Encryption: AES-128-CFB8 (segment_size=8), no padding. Static codec basis + state vector embedded in
protocol.py. Session key derived per-connection from a random 6-character handshake token viaderive_session_key(). - Handshake: byte
0x05+ 6-char ASCII token, sent unencrypted to the write characteristic. Device acks with a0x05 0x00-prefixed packet. - Read command:
{"act":"1","cmd":"local","data":[{"k":key}...],"tid":"10001"}, AES-encrypted, written to the write characteristic. Response arrives via notifications, possibly split across multiple packets, reassembled and decrypted. - Write command:
{"act":"3","cmd":"local","data":[{"k":key,"v":value}],"tid":"10001"}. Values must be plain integers, not strings. Successful write replies with{"<key>": 0}(0 = success acknowledgement). - Per-device addressing: the inverter and each individual battery pack are separate BLE peripherals, each with their own advertised name and address — not sub-nodes reached through a single connection. Device type is identifiable directly from the advertised name: inverter loggers begin
HC:L110, battery pack loggers beginHC:L101. The same handshake/encryption/read protocol works identically against either — connect to the specific pack's own address and the same{"act":"1",...}read command structure applies, just requesting B-codes instead of P/L-codes.
These were discovered during live testing and are critical for correct operation:
- Integer values for writes — values sent via
async_write_value()must be plain Python integers, not strings. Sending a string (e.g."1"instead of1) causes the device to respond with a plaintext error notification that the packet parser won't recognise, taking down the whole operation. - CoordinatorEntity for write entities — select/number/time entities must inherit
CoordinatorEntityso their displayed state stays live across coordinator poll cycles. Without this, values get stuck at "unknown" after an integration reload. - asyncio.Lock for BLE connection —
HanchuBleClientholds a connection lock acquired by both reads and writes. Without this, the coordinator's scheduled poll can collide with a manual write, causing 20s+ connect timeouts and failed operations. - Hardened packet parsing —
parse_packet()logs-and-skips rather than raising on unrecognised notification types, so unexpected device responses (e.g. plaintext error messages) don't crash the active operation. - Epoch registers need explicit datetime conversion — a register returning a raw Unix epoch integer (e.g. L094) will display as a meaningless, constantly-different number if exposed as a plain sensor. It must be given
device_class=SensorDeviceClass.TIMESTAMPand have its value converted viadatetime.fromtimestamp(value, tz=timezone.utc)before being returned fromnative_value— HA does not auto-convert raw integers for the timestamp device class. - Full BLE operation needs one outer timeout, not just connection setup —
establish_connection()isn't the only call that can hang under BLE proxy contention;start_notify(),write_gatt_char(), and especiallydisconnect()(called in afinallyblock) have no timeout of their own and can block indefinitely on some Bleak/BlueZ backends. Wrapping only the connection step is not enough — wrap the entire connect → notify → handshake → write → disconnect sequence in a singleasyncio.wait_for(..., timeout=60.0)so a hang anywhere in the chain surfaces as a clean, catchableTimeoutErrorinstead of freezing the coordinator's update cycle permanently with no exception and no failure logged. DataUpdateCoordinatorcreated inside another coordinator'sasync_setup()may not reliably reschedule itself —HanchuBatteryCoordinatorinstances are created dynamically inside the parent inverter coordinator'sasync_setup(), rather than through HA's normal top-levelasync_setup_entry()flow. In testing, the base class's built-in periodic rescheduling ran the first refresh correctly but then never re-armed — entities went stale indefinitely with zero errors logged and failure counters frozen at 0, while a manual refresh (homeassistant.update_entity) always worked perfectly, confirming the read pipeline itself was healthy. Fixed by passingupdate_interval=Noneto disable the base class's own scheduling entirely, and instead driving refreshes explicitly viahomeassistant.helpers.event.async_track_time_intervalcallingcoordinator.async_request_refresh(). Don't do both (i.e. don't set a realupdate_intervaland add an explicit timer) — the two schedulers can run concurrently, causing far more frequent, overlapping refresh attempts than intended and genuine race-condition errors from concurrent access to coordinator state.- A multi-entry write payload (multiple
{"k":...,"v":...}objects in onedataarray) reports false success — the read side is confirmed to accept a multi-entrydataarray (used for batched reads acrossFAST_POLL_KEYS/SLOW_POLL_KEYS), which suggested by analogy that a multi-key write might work the same way. Bench-testing this directly on real hardware (Sept 2026) found the opposite: the device replied with a0(success) status for every key in the batch, and an immediate read-back even echoed the new values — but the underlying registers were not actually changed on the device or reflected in the vendor app. Do not rely on a multi-entry write payload or its reply/read-back as confirmation that values were committed. The reliable pattern for applying several writes together is to open one BLE connection and send each as its own single-keyact:"3"request in sequence before disconnecting (seeasync_write_values()inble_client.py) — this avoids per-write connection overhead without depending on unverified multi-key write semantics. - A register can report a commanded value before the physical change has actually completed — confirmed specifically for P500 (see below), where the register flips to the new value well before the inverter has finished a state transition that visibly takes much longer. Any consumer treating "register now reads the new value" as equivalent to "the physical change is finished" should verify that assumption per-register rather than assuming it generally, especially for registers tied to hardware transitions (relays, mode changes) rather than simple settings.
| Code | Name | Description | Unit | Notes |
|---|---|---|---|---|
| P000 | Phase Mode | AC phase configuration — 0 = single, 3 = three | Live value 1 on some single-phase units |
|
| P002 | Inverter Serial Number | |||
| P003 | Inverter Model | e.g. "HYB-5K" | ||
| P005 | Inverter Power Limit | Rated power limit of the inverter | W | Used as dynamic max for charge/discharge power limit entities |
| P006 | Inverter Main Firmware Version | e.g. V610-02003-25 | ||
| P007 | Inverter Safety Firmware Version | e.g. V610-10008-11 | ||
| P008 | Inverter Brand | e.g. "HANCHU" | ||
| P011 | Parameter P011 | Static ~50.00 across reads | Likely a rated/reference frequency value | |
| P024 | PV String 1 Voltage | DC-coupled PV input | V | ~0 on AC-coupled systems |
| P025 | PV String 1 Current | DC-coupled PV input | A | ~0 on AC-coupled systems |
| P026 | PV String 2 Voltage | DC-coupled PV input | V | ~0 on AC-coupled systems |
| P027 | PV String 2 Current | DC-coupled PV input | A | ~0 on AC-coupled systems |
| P028 | PV String 3 Voltage | DC-coupled PV input, 3-MPPT inverters only | V | Confirmed against real 3-MPPT hardware by @techjlb — follows the same +2 register offset per string as PV1/PV2. ~0 on 2-MPPT or AC-coupled systems |
| P029 | PV String 3 Current | DC-coupled PV input, 3-MPPT inverters only | A | Confirmed against real 3-MPPT hardware by @techjlb — follows the same +2 register offset per string as PV1/PV2. ~0 on 2-MPPT or AC-coupled systems |
| P044 | Grid Voltage L1 | V | ||
| P045 | Grid Current L1 | A | ||
| P046 | Grid Parameter (P046) | Returns None — unsupported on tested hardware/firmware |
||
| P048 | Grid Parameter (P048) | Returns None — unsupported on tested hardware/firmware |
||
| P053 | Grid Frequency | Hz | ~50.00 on UK grid | |
| P055 | Active Power | Net active power at inverter terminals | W | Reads ~0 during AC-coupled solar export — not equivalent to total grid power |
| P056 | Grid Reactive Power | Var | ||
| P057 | Grid Power Factor | 0.00–1.00 | Correlates strongly with grid import/export direction on tested systems | |
| P060 | Total PV Power | DC-coupled combined PV output | W | ~0 on AC-coupled systems |
| P061 | PV Energy Today | DC-coupled | kWh | ~0 on AC-coupled systems |
| P062 | PV Energy Accumulated | DC-coupled | kWh | ~0 on AC-coupled systems |
| P063 | Battery Comms Status | 0 = disconnected, 1 = connected | ||
| P064 | Battery State | 0 = disconnected, 1 = normal, 2 = charging, 3 = discharging | ||
| P067 | Battery Voltage (alt) | Inverter-side reading | V | |
| P068 | Battery Current (alt) | Inverter-side reading | A | |
| P069 | Battery Power | Positive = discharging, negative = charging — note: the hanchu-params.js registry has this backwards |
W | The integration applies ×-1 scale factor to match cloud convention (positive = charging) |
| P070 | Battery Temperature | °C | ||
| P071 | Battery SOC (decimal) | e.g. 0.30 = 67% | % | Integration applies ×100 scale factor to convert to percentage |
| P075 | Battery Charge Today | kWh | ||
| P076 | Battery Discharge Today | kWh | ||
| P079 | EPS Voltage | EPS output during grid outage | V | |
| P080 | EPS Current | A | ||
| P081 | EPS Frequency | Hz | ||
| P082 | EPS Active Power | W | ||
| P083 | EPS Reactive Power | Var | ||
| P084 | EPS Energy Today | kWh | ||
| P085 | EPS Energy Accumulated | kWh | ||
| P088 | Battery Capacity | Rated capacity | Ah | Convert to kWh via Ah × 51.2 / 1000 (integration applies ×0.0512 scale factor) |
| P139 | ARM Firmware Version | |||
| P142 | Battery Pack Count | |||
| P237 | AC Coupled PV Power | Live AC-coupled solar power | W | Sign convention varies between hardware/firmware versions — some return positive when generating, others negative. The integration uses abs() in the Load Power calculation to handle both conventions correctly. |
| P644 | Grid Power | Live grid import/export | W | Confirmed live. Positive = importing, negative = exporting |
| L023 | DTU Firmware Version | e.g. 1.9.0 | ||
| L034 | Meter Type / CT Meter Version | 0 = no meter, 3 = specific supported meter | ||
| L020 | Timezone | IANA timezone code reported by the DTU | Confirmed readable — e.g. "Europe/London". Earlier notes assumed write-only; live testing (v1.0.10) confirmed it also returns correctly on read | |
| L094 | RTC Unix Epoch Timestamp | Current Unix epoch time as reported by the DTU's onboard RTC | s (epoch) | Confirmed matches real-world time within normal poll-cycle drift. Exposed as SensorDeviceClass.TIMESTAMP via datetime.fromtimestamp(value, tz=timezone.utc) — raw integer alone displays as a meaningless incrementing number if not converted |
| L096 | RTC Daylight Saving Offset | DST offset as reported by the DTU | min | Correction to original hypothesis — earlier notes assumed hours; live testing (v1.0.10) confirmed the value is in minutes (60 during BST, expected 0 during GMT), consistent with a standard one-hour DST shift |
Unlike P/L-codes, which are read from the inverter's own BLE connection, B-codes are read directly from an individual battery pack's own BLE address — see the per-device addressing note above. Confirmed working against real hardware in v1.1.0; identified from the 1ulk hanchu-params.js registry, which documents these but never wires them up (the reference implementation only ever polls the inverter).
| Code | Name | Description | Unit | Notes |
|---|---|---|---|---|
| B002 | Battery Serial Number | |||
| B034 | Battery SoC | State of charge, direct from the pack's own BMS | % | Matches the official app's per-pack SoC reading directly — no scale factor needed, unlike the inverter's P071 |
| B035 | Battery Pack Voltage | V | ||
| B038 | Environmental Temperature | Ambient temperature at the pack | °C | |
| B039 | Battery Temperature | Cell temperature | °C | |
| B040 | PCBA Temperature | Control board temperature | °C | |
| B043 | Battery Current | Charge/discharge current for that pack | A | Sign convention: negative observed while discharging in testing |
| B145 | Hardware Version | |||
| B146 | Battery Model | |||
| B148 | Firmware Version |
Also documented in the registry but not currently exposed as entities: B005 (Battery Brand), B153 (485A Protocol), B157 (CAN2 Protocol) — recorded for future reference, not yet confirmed against live hardware.
All writes use act: "3" with a plain integer value.
| Code | Name | Description | Unit |
|---|---|---|---|
| P651 | Work Mode | 1=Self-Consumption, 2=Backup, 3=User-Defined, 4=Off-Grid (mirrors L019) | |
| L017 | Charge Power Limit | Maximum battery charge power | W |
| L018 | Discharge Power Limit | Maximum battery discharge power | W |
| P647 | Maximum Charge SOC | Upper SOC limit — battery charges to this level then stops | % |
| P648 | On-Grid Battery Discharge Minimum | Hard floor — battery will not discharge below this level while on-grid | % |
| P772 | Off-Grid Battery Discharge Minimum | Hard floor for off-grid/EPS mode — distinct from P648 | % |
| L074 | Max SOC Limit | Upper SOC limit for grid-to-battery charging | % |
| L005 | Charge Period 1 Start | Seconds from midnight | s |
| L006 | Charge Period 1 End | Seconds from midnight | s |
| L007 | Charge Period 2 Start | Seconds from midnight | s |
| L008 | Charge Period 2 End | Seconds from midnight | s |
| L009 | Charge Period 3 Start | Seconds from midnight; 0 = disabled | s |
| L010 | Charge Period 3 End | Seconds from midnight; 0 = disabled | s |
| L011 | Discharge Period 1 Start | Seconds from midnight; 0 = disabled | s |
| L012 | Discharge Period 1 End | Seconds from midnight; 0 = disabled | s |
| L013 | Discharge Period 2 Start | Seconds from midnight; 0 = disabled | s |
| L014 | Discharge Period 2 End | Seconds from midnight; 0 = disabled | s |
| L015 | Discharge Period 3 Start | Seconds from midnight; 0 = disabled | s |
| L016 | Discharge Period 3 End | Seconds from midnight; 0 = disabled | s |
| P236 | Meter PV Enable | 0 = off, 1 = on | |
| P245 | Meter PV Direction | 0 = import, 1 = export | |
| P500 | Grid Relay / Inverter Power State | 1 = On, 0 = Off — controls the grid relay and overall inverter power state; setting Off stops solar, battery charge/discharge, and EPS output together, equivalent to the physical AC isolator switch. Confirmed the register reports the commanded value well before the physical transition finishes: Off completes in ~7-10s and the register matches quickly, but On was observed reading back as "on" ~20s in during HA testing, despite the inverter still visibly restarting for another ~30s (~50s total, several relay clicks, per PaulDGAL's hardware testing). Treat "register matches" and "physically complete" as separate things for this register, not interchangeable. |
Codes identified in the 1ulk hanchu-params.js registry but not currently exposed as entities. Recorded here for future reference.
| Code | Name | Description | Notes |
|---|---|---|---|
| L021 | Clear WiFi Password | Write trigger to clear stored WiFi credentials | Write only — not suitable as a polled sensor |
| Feature | Status |
|---|---|
| Daily Grid Import (Power Purchased Today) | P638 is documented in the registry but never returned in BLE replies on tested hardware — tested in isolation, batched, and across neighbouring code clusters. No alternative key found. Cloud-only or requires packet capture of official app to identify. |
| Daily Grid Export (Power Sold Today) | Same as P638 — P639 also absent from all BLE replies. |
| Daily Load Energy | No dedicated code found. Can be derived via HA's Riemann sum integral helper on the Load Power sensor. |
| AC-Coupled Daily PV Energy | P237 gives live AC-coupled PV power but no daily total equivalent was found. |
| Fast Charge / Discharge | Does not appear in the local inverter app settings — likely cloud-only via a separate endpoint. |
Tested via live BLE reads but purpose unconfirmed:
| Code | Value(s) seen | Behaviour | Hypothesis |
|---|---|---|---|
| P011 | 50.00 | Static | Rated/reference frequency |
| P046 | None | Unsupported | Not present on tested hardware |
| P048 | None | Unsupported | Not present on tested hardware |
| P240 | ~2015.xx | Slow drift | Possibly a calibration or batch code |
| P241 | ~11.9x | Slow drift | Possibly a calibration constant |
| P498 | ~1.7–2.1 | Live, varies | Unconfirmed measurement |
| P499 | ~240–241 | Live, varies | Likely a secondary voltage reading |
| P640 | ~6000+ | Cumulative | Energy counter — increments daily |
| P641 | ~2700+ | Cumulative | Energy counter — increments daily |
| P642 | ~5900+ | Cumulative | Energy counter — increments daily |
| P643 | ~2700+ | Cumulative | Energy counter — increments daily |
| P644 | Varies, sign-changing | Live | Confirmed Grid Power |
| P685–P693 | No data | Absent | Unsupported/reserved on tested hardware |
| Sensor | Formula | Notes |
|---|---|---|
| Load Power | P644 + abs(P237) + P069 (raw values) |
abs() applied to P237 to handle sign convention variation between hardware/firmware versions |
| Battery Capacity (kWh) | P088 × 0.0512 |
P088 in Ah × 51.2V nominal / 1000 |
| Battery SOC (%) | P071 × 100 |
P071 is a decimal fraction e.g. 0.67 = 67% |
| Battery Power (cloud convention) | P069 × -1 |
Inverts to positive=charging to match cloud convention |
- Blustery7752 — original
hanchu-ess-bleintegration providing the BLE connection, encryption, and read foundation - 1ulk — Hanchu BLE Controller web app whose
hanchu-params.jsparameter registry andhanchu-controller.jswrite implementation were essential references - PaulDGAL — real-world testing identifying BLE load and sign convention issues, proposing the tiered polling approach, discovering/mapping the RTC and firmware diagnostic registers (L020, L094, L096) later implemented in v1.0.10, and identifying the P500 register plus hands-on hardware testing of its full transition behaviour and timing (v1.3.0)
- Dean (upton68) — bench-testing that identified the multi-entry write false-success behaviour, and real-hardware testing that identified P500's early-confirmation timing discrepancy on power-on