Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# AGENTS.md

## About This Repository

This is a Home Assistant custom integration for **Solarman Stick Loggers**. It enables Home Assistant to communicate with solar inverters via Solarman data loggers and compatible Modbus TCP bridges (such as ESPHome Modbus bridges, Waveshare RS485-to-ETH adapters, and Ethernet loggers).

The integration is built on top of the asynchronous [pysolarmanv5](https://github.com/jmccrohan/pysolarmanv5) library and supports both the Solarman V5 protocol and raw Modbus TCP.

## Bexie Hybrid Inverter — Modbus TCP at 192.168.178.134

A device profile has been created at:
`custom_components/solarman/inverter_definitions/bexie_hybrid.yaml`

### Device Details

| Field | Value |
|-------------|------------------------|
| Host | `mass` |
| Port | 1502 |
| Protocol | Modbus TCP |
| Phases | Single phase |
| MPPTs | 2 |
| HA instance | 192.168.178.231:8123 |

`mass:1502` is the current known-good address (previously documented as `192.168.178.134:502` — IP/port can drift, `mass` is the stable hostname to use going forward).

### Register Map (confirmed via live probe)

| Block | Range | FC | Contents |
|---------------|-----------------|-----|-----------------------------------|
| PV / Inverter | 0x1010–0x104C | 03 | PV voltage, current, power, temps |
| Grid / Load | 0x1300–0x1338 | 03 | Grid/load power, voltage, freq |
| Battery | 0x2000–0x200F | 03 | SOC, voltage, current, power |
| Control (RW) | 0x2100–0x2115 | 03/06 | Work mode, grid charge |

### Key Device-Specific Notes

- **Battery Temperature**: `0x201B` (U16) — confirmed correct. `0x2001` returns 0 on this device (differs from CHINT).
- **32-bit registers**: use inverted word order `[high_addr, low_addr]` — same as CHINT CPS-SCETL.
- **Work Mode** (`0x2100`): writable. Currently reads `0` (Self Use). Confirmed write/readback works.
- **Grid Charge** (`0x2115`): writable switch. Currently reads `0` (Disabled).
- **Port 502** is only open while the inverter is active. The port closes at night / when HA holds the connection — disable HA before probing directly.

### Reference

- Modbus protocol document (same as CHINT): https://github.com/user-attachments/files/17295986/Hybrid.Modbus.Protocol.per.inverter.ibridi.pdf
- Similar profile: `custom_components/solarman/inverter_definitions/chint_cps-scetl.yaml`

### Tools

Check this section before asking the user how to test against the real device — no need to prompt for connection details or write a one-off Modbus script.

- `tools/probe_registers.py` — reads and validates all key registers on the live Bexie device, flags implausible values, and is useful for confirming/refuting a register-decode hypothesis (e.g. word order, sign, overflow) against real hardware rather than guessing from datasheet notes alone. Run with:
```
python3 tools/probe_registers.py --host mass --port 1502
```
(defaults to `--host 192.168.178.134 --port 502` if omitted — pass `--host mass --port 1502` explicitly, that's the current known-good address).
44 changes: 43 additions & 1 deletion custom_components/solarman/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,49 @@ def ensure_list_safe_len(value: list):
def create_request(code: int, start: int, end: int):
return { REQUEST_CODE: code, REQUEST_START: start, REQUEST_END: end, REQUEST_COUNT: end - start + 1 }

async def lookup_profile(request, parameters):
def decode_ascii(data, code, registers):
value = ""

for r in registers:
if (temp := get_addr_value(data, code, r)) is None:
return None

value += chr(temp >> 8) + chr(temp & 0xFF)

return value

async def lookup_profile(request, parameters, directory):
# A profile can declare its own identification under "autodetection" (code/start/end
# to probe, "equals" to match the decoded ASCII value against — a single string or a
# list of confirmed strings, e.g. distinct model numbers on the same register layout),
# so adding autodetection support for a new profile is a YAML-only change. Tried before
# the Deye-specific probe below: an explicit, confirmed exact-string match is higher
# confidence than inferring a profile from a numeric code at register 0.
for file in sorted(Path(directory).glob("*.yaml")):
try:
if not (auto := (await yaml_open(file)).get("autodetection")):
continue
except Exception:
continue

try:
response = await request(requests = create_request(auto["code"], auto["start"], auto["end"]))
except TimeoutError:
raise
except Exception:
continue

if response and (value := decode_ascii(response, auto["code"], list(range(auto["start"], auto["end"] + 1)))) is not None:
# Padding byte is device-specific and unconfirmed (nulls, spaces, 0xFF, ...),
# so strip anything outside printable ASCII rather than assuming which one it is.
filtered = "".join(c for c in value if "\x20" <= c <= "\x7e").strip()
equals = auto["equals"]
if filtered in (equals if isinstance(equals, list) else (equals,)):
return file.name

return await lookup_profile_deye(request, parameters)

async def lookup_profile_deye(request, parameters):
if (response := await request(requests = create_request(*AUTODETECTION_REQUEST_DEYE))) and (device_type := get_addr_value(response, *AUTODETECTION_DEVICE_DEYE)):
try:
f, m, c = next(iter([AUTODETECTION_DEYE[i] for i in AUTODETECTION_DEYE if device_type in i]))
Expand Down
Loading