Skip to content
Merged
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
2 changes: 1 addition & 1 deletion custom_components/solisconnect/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@

_LOGGER = logging.getLogger(__name__)

PLATFORMS = [Platform.NUMBER, Platform.SWITCH, Platform.TIME, Platform.SELECT]
PLATFORMS = [Platform.NUMBER, Platform.SWITCH, Platform.BINARY_SENSOR, Platform.TIME, Platform.SELECT]

SCHEME_HOLDING_REGISTER = vol.Schema(
{
Expand Down
29 changes: 29 additions & 0 deletions custom_components/solisconnect/binary_sensor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import logging

from homeassistant.config_entries import ConfigEntry

from custom_components.solisconnect import ModbusController
from custom_components.solisconnect.const import BINARY_SENSOR_ENTITIES, DOMAIN, ENTITIES
from custom_components.solisconnect.helpers import get_controller_from_entry
from custom_components.solisconnect.sensor_data.binary_sensors import get_binary_sensors
from custom_components.solisconnect.sensors.solis_binary_status_sensor import SolisBinaryStatusSensor

_LOGGER = logging.getLogger(__name__)


async def async_setup_entry(hass, config_entry: ConfigEntry, async_add_devices):
modbus_controller: ModbusController = get_controller_from_entry(hass, config_entry)

binary_groups = get_binary_sensors(modbus_controller.inverter_config)

binary_entities: list[SolisBinaryStatusSensor] = []

for main_entity in binary_groups:
for child_entity in main_entity[ENTITIES]:
child_entity["register"] = main_entity.get("register", main_entity.get("read_register"))
binary_entities.append(SolisBinaryStatusSensor(hass, modbus_controller, child_entity))

hass.data[DOMAIN].setdefault(BINARY_SENSOR_ENTITIES, []).extend(binary_entities)
async_add_devices(binary_entities, True)

return True
1 change: 1 addition & 0 deletions custom_components/solisconnect/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
SENSOR_ENTITIES = "sensor_entities"
TIME_ENTITIES = "time_entities"
SWITCH_ENTITIES = "switch_entities"
BINARY_SENSOR_ENTITIES = "binary_sensor_entities"
NUMBER_ENTITIES = "number_entities"
SENSOR_DERIVED_ENTITIES = "sensor_derived_entities"
DRIFT_COUNTER = "drift_counter"
Expand Down
26 changes: 26 additions & 0 deletions custom_components/solisconnect/sensor_data/binary_sensors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from custom_components.solisconnect.data.enums import InverterType


def get_binary_sensors(inverter_config):
"""Read-only bit-based status sensors (no write path, unlike switch_sensors.py)."""
binary_sensors = []

if inverter_config.type == InverterType.HYBRID:
binary_sensors = [
{
"register": 43110,
"entities": [
# Bit 1 of the Energy Storage Control Switch does not persist as a
# user-set value on this firmware family: writing it succeeds and is
# acknowledged, but the device silently clears it again within one poll
# cycle while leaving every other bit untouched. Confirmed live and
# cross-referenced against https://github.com/wills106/homeassistant-solax-modbus
# (plugin_solis_fb00.py), which never exposes this bit as a controllable
# option either. Real TOU enablement lives entirely in the per-slot
# switches on register 43707. Exposed here read-only for visibility only.
{"bit_position": 1, "name": "Timed Charge Status"},
],
},
]

return binary_sensors
29 changes: 17 additions & 12 deletions custom_components/solisconnect/sensor_data/select_sensors.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,23 @@ def get_select_sensors(inverter_config):
"name": "Work Mode",
"control": True,
"entities": [
# Adheres to RS485_MODBUS ESINV-33000ID Hybrid Inverter V3.2 / Appendix 8.
# Each option clears every OTHER mode bit it is incompatible with; the
# previous lists conflicted with their own bit and never cleared the
# Timed Charge bit (1), so switching modes could leave invalid bit
# combos that the inverter rejects/normalises (controls "bounce off"),
# and plain "Self-Use"/"Feed-in" could never turn TOU back off.
{"bit_position": 0, "name": "Self-Use", "conflicts_with": [1, 2, 6, 11]},
{"bit_position": 0, "name": "Self-Use + TOU", "conflicts_with": [2, 6, 11], "requires": [1]},
{"bit_position": 2, "name": "Off-Grid Operation", "conflicts_with": [0, 1, 6, 11]},
{"bit_position": 6, "name": "Feed-in Priority", "conflicts_with": [0, 1, 2, 11]},
{"bit_position": 6, "name": "Feed-in + TOU", "conflicts_with": [0, 2, 11], "requires": [1]},
{"bit_position": 11, "name": "Peak Shaving", "conflicts_with": [0, 1, 2, 4, 6]},
# Whole-register values, not per-bit conflicts_with/requires logic.
# Bit-conflict resolution previously used here could compute a
# technically valid, conflict-free combo (per RS485_MODBUS Appendix 7)
# that the firmware would still silently reject/normalise (e.g. bit 1,
# "Timed Charge", never persists on this firmware family - see
# binary_sensors.py). These 8 values are copied from the field-tested
# option_dict in https://github.com/wills106/homeassistant-solax-modbus
# (plugin_solis_fb00.py, "Energy Storage Control Switch"), which never
# includes bit 1 for the same reason.
{"name": "Self-Use - No Grid Charging", "on_value": 1},
{"name": "Backup/Reserve - No Grid Charging", "on_value": 17},
{"name": "Self-Use", "on_value": 33},
{"name": "Off-Grid Mode", "on_value": 37},
{"name": "Battery Awaken", "on_value": 41},
{"name": "Backup/Reserve", "on_value": 49},
{"name": "Feed-in priority - No Grid Charging", "on_value": 64},
{"name": "Feed-in priority", "on_value": 96},
],
},
]
Expand Down
7 changes: 3 additions & 4 deletions custom_components/solisconnect/sensor_data/switch_sensors.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,10 @@ def get_switch_sensors(inverter_config):
"register": 43110,
"entities": [
# Adheres to RS485_MODBUS ESINV-33000ID Hybrid Inverter V3.2 / Appendix 8
# Bit 1 (Timed Charge / TOU) is intentionally not exposed as a control
# here - it does not persist on this firmware family. See
# sensor_data/binary_sensors.py for the read-only status equivalent.
{"bit_position": 0, "name": "Mode - Self Use", "conflicts_with": [2, 6, 11], "control": True},
# Timed Charge (TOU) only operates alongside Self-Use (0) or Feed-In (6);
# clear Off-Grid/Peak-Shaving first or the fallback Self-Use bit forms a
# combo the firmware rejects and the switch bounces off on the next poll.
{"bit_position": 1, "name": "Mode - Timed Charge", "requires_any": [0, 6], "conflicts_with": [2, 11], "control": True},
{"bit_position": 2, "name": "Mode - Off-Grid", "conflicts_with": [0, 1, 6, 11], "control": True},
{"bit_position": 3, "name": "Mode - Battery Wake Up", "control": True},
{"bit_position": 4, "name": "Mode - Backup", "conflicts_with": [11], "control": True},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import logging

from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.restore_state import RestoreEntity

from custom_components.solisconnect import ModbusController
from custom_components.solisconnect.const import REGISTER, VALUE
from custom_components.solisconnect.helpers import (
cache_get,
is_correct_controller,
register_update_signal,
unique_id_generator_binary,
)
from custom_components.solisconnect.sensors.solis_binary_sensor import get_bit_bool

_LOGGER = logging.getLogger(__name__)


class SolisBinaryStatusSensor(RestoreEntity, BinarySensorEntity):
"""Read-only bit status. No write path - see SolisBinaryEntity for controllable switches."""

def __init__(self, hass, modbus_controller, entity_definition):
self._hass = hass
self._modbus_controller: ModbusController = modbus_controller
self._register: int = entity_definition.get("register", entity_definition.get("read_register")) + entity_definition.get("offset", 0)
self._bit_position = entity_definition.get("bit_position", None)
self._on_value = entity_definition.get("on_value", None)
self._inverted = entity_definition.get("inverted", None)
self._attr_unique_id = unique_id_generator_binary(modbus_controller, self._register, self._bit_position, self._on_value)
self._attr_name = entity_definition["name"]
self._attr_available = False

async def async_added_to_hass(self) -> None:
"""Called when entity is added to HA."""
await super().async_added_to_hass()

self.async_on_remove(
async_dispatcher_connect(
self._hass,
register_update_signal(self._modbus_controller, self._register),
self.handle_modbus_update,
)
)

@callback
def handle_modbus_update(self, data):
"""Callback when register data is available (per-register dispatcher)."""
updated_register = int(data.get(REGISTER))

if not is_correct_controller(self._modbus_controller, data):
return # meant for a different sensor/inverter combo

if updated_register != self._register:
return

updated_value = data.get(VALUE)
value = int(updated_value) if updated_value is not None else cache_get(self._hass, self._modbus_controller, self._register)
if value is None:
return

self._attr_available = True
if self._bit_position is not None:
raw_bit = get_bit_bool(value, self._bit_position)
self._attr_is_on = (not raw_bit) if self._inverted else raw_bit
elif self._on_value is not None:
self._attr_is_on = value == self._on_value

self.schedule_update_ha_state()

@property
def is_on(self):
return self._attr_is_on

@property
def device_info(self):
"""Return device info."""
return self._modbus_controller.device_info
26 changes: 13 additions & 13 deletions docs/source/sensors.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ myst:

The following sensors are provided in the integration.

Local Modbus entries can populate the full register set for the selected inverter model. SolisCloud entries use the same entity definitions, but only registers covered by the current cloud field/CID mappings update in cloud-only mode; unsupported cloud-only entities are marked unavailable. TOU V2 timed-slot CIDs are mapped into the local `43707-43791` register model, but automatic TOU V2 feature gating from cloud CID `6798` is still pending.
Local Modbus entries can populate the full register set for the selected inverter model. SolisCloud entries use the same entity definitions, but only registers covered by the current cloud field/CID mappings update in cloud-only mode; unsupported cloud-only entities are marked unavailable. TOU V2 timed-slot CIDs are mapped into the local `43707-43791` register model, and automatic TOU V2 feature gating from cloud CID `6798` disables those entities in cloud-only mode when the inverter does not support V2.

# String Inverter Registers
The string inverter uses the following register ranges:
Expand Down Expand Up @@ -35,18 +35,18 @@ The hybrid inverter uses the following register ranges:
# Switch Control Sensors
| Name | Register | Bit Position | Note |
|-------------------------------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Solis Self-Use Mode | 43110 | 0 |
| Solis Time Of Use Mode | 43110 | 1 |
| Solis OFF-Grid Mode | 43110 | 2 |
| Solis Battery Wakeup Switch | 43110 | 3 |
| Solis Reserve Battery Mode | 43110 | 4 |
| Solis Allow Grid To Charge The Battery | 43110 | 5 |
| Solis Feed In Priority Mode | 43110 | 6 |
| Solis Batt OVC | 43110 | 7 |
| Solis Battery Forcecharge Peakshaving | 43110 | 8 |
| Solis Battery current correction | 43110 | 9 |
| Solis Battery healing mode | 43110 | 10 |
| Solis Peak-shaving mode | 43110 | 11 |
| Mode - Self Use | 43110 | 0 |
| Timed Charge Status | 43110 | 1 | **Read-only** `binary_sensor` (not a switch): this bit does not persist as a user-set value on this firmware family. Use register `43707` per-slot switches for real TOU enablement. |
| Mode - Off-Grid | 43110 | 2 |
| Mode - Battery Wake Up | 43110 | 3 |
| Mode - Backup | 43110 | 4 |
| Mode - Grid Charge | 43110 | 5 |
| Mode - Feed In Priority | 43110 | 6 |
| Mode - Night OVD Retain | 43110 | 7 |
| Battery Forcecharge Peakshaving | 43110 | 8 | Disabled by default |
| Battery Current Correction | 43110 | 9 | Disabled by default |
| Mode - Battery Condition | 43110 | 10 |
| Mode - Peak Shaving | 43110 | 11 |
| Solis Generator connection position | 43365 | 0 |
| Solis With Generator | 43365 | 1 |
| Solis Generator enable signal | 43365 | 2 |
Expand Down
4 changes: 2 additions & 2 deletions docs/source/tou.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ The config flow's "V2 support" toggle is auto-detected from the inverter's HMI f

| | Time-Charging (V1) | Grid Time of Use V2 |
| --- | --- | --- |
| Enable switch | `switch.solis_time_of_use` — bit 1 of storage-mode register `43110` | `switch.solis_time_of_use_v2_switch` — bitfield register `43707`, one bit per slot |
| Enable switch | **Read-only** — bit 1 of storage-mode register `43110`, exposed as `binary_sensor.solis_timed_charge_status` (see note below; not user-settable on this firmware family) | Per-slot `switch.solis_timed_charge_enable_N` / `switch.solis_timed_discharge_enable_N` — bitfield register `43707`, one bit per slot |
| Slot count | 3 | 6 |
| Per-slot fields | Start/end hour+minute for charge and discharge | Start/end hour+minute, target SOC, cut-off voltage, battery current, per slot, for charge and discharge |
| Charge/discharge current | One global pair (`43141`/`43142`), not per-slot | Per-slot (register offset `+1` in each slot block) |
Expand All @@ -22,7 +22,7 @@ The config flow's "V2 support" toggle is auto-detected from the inverter's HMI f

The older scheduling mechanism. One shared charge current and one shared discharge current apply across all slots; only the start/end times vary per slot.

- Enable: bit 1 (`Time of Use`) of the storage-mode bitfield at register `43110` (`switch_sensors.py`). This bit requires either `Self-Use Mode` (bit 0) or `Feed-In Priority Mode` (bit 6) to be conflict-free with the other storage-mode bits.
- Enable: bit 1 (`Time of Use`) of the storage-mode bitfield at register `43110`. **This bit is read-only in SolisConnect** — exposed as `binary_sensor.solis_timed_charge_status` (`sensor_data/binary_sensors.py`), not a controllable switch. On this inverter firmware family the bit does not persist as a user-set value: a Modbus write to set it succeeds and is acknowledged, but the device silently clears it again within one poll cycle while leaving every other storage-mode bit untouched. This was confirmed live and cross-referenced against [wills106/homeassistant-solax-modbus](https://github.com/wills106/homeassistant-solax-modbus) (`plugin_solis_fb00.py`), which never exposes this bit as a controllable option either and drives all real TOU enablement through register `43707`'s per-slot switches. The `Work Mode` select (register `43110`) likewise now writes only the 8 field-tested whole-register values from that project (`1, 17, 33, 37, 41, 49, 64, 96`), none of which set bit 1.
- Global settings: `Time-Charging Charge Current` (`43141`) and `Time-Charging Discharge Current` (`43142`), both in amps, editable as `number` entities.
- Per slot, each slot is a block of paired hour/minute registers exposed as HA `time` entities:
- Charge Start / Charge End
Expand Down
10 changes: 10 additions & 0 deletions tests/generate_known_sensors.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class MockController:
known_sensors.append(uid)

from custom_components.solisconnect.data.solis_config import InverterConfig, InverterType
from custom_components.solisconnect.sensor_data.binary_sensors import get_binary_sensors
from custom_components.solisconnect.sensor_data.select_sensors import get_select_sensors
from custom_components.solisconnect.sensor_data.switch_sensors import get_switch_sensors
from custom_components.solisconnect.sensor_data.time_sensors import get_time_sensors
Expand All @@ -53,6 +54,15 @@ class MockController:
uid = unique_id_generator_binary(controller, register, bit_position, on_value)
known_sensors.append(uid)

# Binary sensor (read-only bit status; same unique_id scheme as switch)
for group in get_binary_sensors(config):
for entity in group["entities"]:
register = group.get("register", group.get("read_register")) + entity.get("offset", 0)
bit_position = entity.get("bit_position")
on_value = entity.get("on_value")
uid = unique_id_generator_binary(controller, register, bit_position, on_value)
known_sensors.append(uid)

# Select
select_groups = get_select_sensors(config)
for entity in select_groups:
Expand Down
Loading
Loading