Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6692bfe984 | ||
|
|
439f2a46f0 | ||
|
|
a54fa78a5f | ||
|
|
9079a9868c | ||
|
|
d71a434a85 | ||
|
|
4e8e20a5ac | ||
|
|
9580df558a | ||
|
|
932baef234 |
@@ -0,0 +1,46 @@
|
|||||||
|
name: Create Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Release version (e.g., 1.1.5)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Update manifest.json
|
||||||
|
run: |
|
||||||
|
sed -i 's/"version": ".*"/"version": "${{ github.event.inputs.version }}"/' custom_components/rdz_hmi_control/manifest.json
|
||||||
|
|
||||||
|
- name: Commit manifest change
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add custom_components/rdz_hmi_control/manifest.json
|
||||||
|
git commit -m "Bump version to ${{ github.event.inputs.version }}"
|
||||||
|
git push
|
||||||
|
|
||||||
|
- name: Create tag
|
||||||
|
run: |
|
||||||
|
git tag v${{ github.event.inputs.version }}
|
||||||
|
git push origin v${{ github.event.inputs.version }}
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
uses: actions/create-release@v1
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
tag_name: v${{ github.event.inputs.version }}
|
||||||
|
release_name: v${{ github.event.inputs.version }}
|
||||||
|
draft: true
|
||||||
|
prerelease: false
|
||||||
@@ -15,13 +15,33 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
|||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
|
CONF_LINKED_VIRTUAL_ZONE,
|
||||||
|
CONF_ZONE_NAME,
|
||||||
|
CONF_ZONE_TYPE,
|
||||||
|
CONF_ZONES,
|
||||||
|
DATA_DEHUMIDIFICATION_PUMP,
|
||||||
|
DATA_HUMIDITY_REQUEST,
|
||||||
|
DATA_INTEGRATION_REQUEST,
|
||||||
DATA_PUMP_ACTIVE,
|
DATA_PUMP_ACTIVE,
|
||||||
|
DATA_RENEWAL_REQUEST,
|
||||||
|
DATA_VENTILATION_REQUEST,
|
||||||
|
DATA_ZONE_ACTIVITY,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
|
THERMOSTAT_TYPE_REAL,
|
||||||
)
|
)
|
||||||
from .coordinator import RDZDataUpdateCoordinator
|
from .coordinator import RDZDataUpdateCoordinator
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Zone request types with their data keys and display names
|
||||||
|
ZONE_REQUEST_TYPES: dict[str, tuple[str, str]] = {
|
||||||
|
"humidity_request": (DATA_HUMIDITY_REQUEST, "Humidity request"),
|
||||||
|
"ventilation_request": (DATA_VENTILATION_REQUEST, "Ventilation request"),
|
||||||
|
"renewal_request": (DATA_RENEWAL_REQUEST, "Renewal request"),
|
||||||
|
"integration_request": (DATA_INTEGRATION_REQUEST, "Integration request"),
|
||||||
|
"dehumidification_pump": (DATA_DEHUMIDIFICATION_PUMP, "Dehumidification pump"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
@@ -30,6 +50,7 @@ async def async_setup_entry(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Set up RDZ HMI binary sensor entities from a config entry."""
|
"""Set up RDZ HMI binary sensor entities from a config entry."""
|
||||||
coordinator: RDZDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
|
coordinator: RDZDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
|
||||||
|
zones_config = config_entry.data.get(CONF_ZONES, {})
|
||||||
|
|
||||||
entities: list[BinarySensorEntity] = []
|
entities: list[BinarySensorEntity] = []
|
||||||
|
|
||||||
@@ -37,6 +58,17 @@ async def async_setup_entry(
|
|||||||
for pump_id in range(1, 9):
|
for pump_id in range(1, 9):
|
||||||
entities.append(RDZPumpActiveBinarySensor(coordinator, pump_id))
|
entities.append(RDZPumpActiveBinarySensor(coordinator, pump_id))
|
||||||
|
|
||||||
|
# Add zone pump binary sensors for each configured zone
|
||||||
|
for zone_id_str, zone_data in zones_config.items():
|
||||||
|
zone_id = int(zone_id_str) if isinstance(zone_id_str, str) else zone_id_str
|
||||||
|
entities.append(RDZZonePumpBinarySensor(coordinator, zone_id, zone_data))
|
||||||
|
|
||||||
|
# Add zone request binary sensors (5 per zone)
|
||||||
|
for request_type in ZONE_REQUEST_TYPES:
|
||||||
|
entities.append(
|
||||||
|
RDZZoneRequestBinarySensor(coordinator, zone_id, zone_data, request_type)
|
||||||
|
)
|
||||||
|
|
||||||
async_add_entities(entities)
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
|
||||||
@@ -85,3 +117,158 @@ class RDZPumpActiveBinarySensor(CoordinatorEntity[RDZDataUpdateCoordinator], Bin
|
|||||||
def _handle_coordinator_update(self) -> None:
|
def _handle_coordinator_update(self) -> None:
|
||||||
"""Handle updated data from the coordinator."""
|
"""Handle updated data from the coordinator."""
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
|
||||||
|
class RDZZonePumpBinarySensor(CoordinatorEntity[RDZDataUpdateCoordinator], BinarySensorEntity):
|
||||||
|
"""Representation of an RDZ HMI zone pump binary sensor.
|
||||||
|
|
||||||
|
Indicates whether the zone is actively heating or cooling.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
_attr_device_class = BinarySensorDeviceClass.RUNNING
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: RDZDataUpdateCoordinator,
|
||||||
|
zone_id: int,
|
||||||
|
zone_data: dict,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the zone pump binary sensor entity."""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self._zone_id = zone_id
|
||||||
|
self._zone_data = zone_data
|
||||||
|
|
||||||
|
zone_name = zone_data.get(CONF_ZONE_NAME, f"Zone {zone_id}")
|
||||||
|
|
||||||
|
self._attr_unique_id = f"{DOMAIN}_{coordinator.client.host}_{zone_id}_zone_pump"
|
||||||
|
self._attr_name = "Zone pump"
|
||||||
|
|
||||||
|
# Place on the zone's device
|
||||||
|
self._attr_device_info = DeviceInfo(
|
||||||
|
identifiers={(DOMAIN, f"{coordinator.client.host}_{zone_id}")},
|
||||||
|
name=zone_name,
|
||||||
|
manufacturer="RDZ",
|
||||||
|
model="HMI Thermostat",
|
||||||
|
sw_version="1.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool | None:
|
||||||
|
"""Return True if the zone is actively heating or cooling.
|
||||||
|
|
||||||
|
Uses the same logic as hvac_action in climate.py:
|
||||||
|
- For real thermostats: True if this zone or linked virtual zone is active
|
||||||
|
- For virtual/unconfigured: Always False
|
||||||
|
"""
|
||||||
|
zone_type = self._zone_data.get(CONF_ZONE_TYPE)
|
||||||
|
|
||||||
|
if zone_type != THERMOSTAT_TYPE_REAL:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.coordinator.data is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
zone_activity = self.coordinator.data.get(DATA_ZONE_ACTIVITY, {})
|
||||||
|
|
||||||
|
# Check if this zone is active
|
||||||
|
if zone_activity.get(self._zone_id, False):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check if linked virtual zone is active
|
||||||
|
linked_virtual = self._zone_data.get(CONF_LINKED_VIRTUAL_ZONE)
|
||||||
|
if linked_virtual is not None and zone_activity.get(linked_virtual, False):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _handle_coordinator_update(self) -> None:
|
||||||
|
"""Handle updated data from the coordinator."""
|
||||||
|
# Update zone_data in case config changed
|
||||||
|
zones_config = self.coordinator.config_entry.data.get(CONF_ZONES, {})
|
||||||
|
self._zone_data = zones_config.get(str(self._zone_id), self._zone_data)
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
|
||||||
|
class RDZZoneRequestBinarySensor(CoordinatorEntity[RDZDataUpdateCoordinator], BinarySensorEntity):
|
||||||
|
"""Representation of an RDZ HMI zone request binary sensor.
|
||||||
|
|
||||||
|
Indicates whether the zone has an active request (humidity, ventilation,
|
||||||
|
renewal, integration, or dehumidification pump).
|
||||||
|
"""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
_attr_device_class = BinarySensorDeviceClass.RUNNING
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: RDZDataUpdateCoordinator,
|
||||||
|
zone_id: int,
|
||||||
|
zone_data: dict,
|
||||||
|
request_type: str,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the zone request binary sensor entity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
coordinator: The data coordinator.
|
||||||
|
zone_id: The zone ID (0-63).
|
||||||
|
zone_data: Zone configuration data.
|
||||||
|
request_type: The type of request (key in ZONE_REQUEST_TYPES).
|
||||||
|
"""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self._zone_id = zone_id
|
||||||
|
self._zone_data = zone_data
|
||||||
|
self._request_type = request_type
|
||||||
|
self._data_key, self._display_name = ZONE_REQUEST_TYPES[request_type]
|
||||||
|
|
||||||
|
zone_name = zone_data.get(CONF_ZONE_NAME, f"Zone {zone_id}")
|
||||||
|
|
||||||
|
self._attr_unique_id = f"{DOMAIN}_{coordinator.client.host}_{zone_id}_{request_type}"
|
||||||
|
self._attr_name = self._display_name
|
||||||
|
|
||||||
|
# Place on the zone's device
|
||||||
|
self._attr_device_info = DeviceInfo(
|
||||||
|
identifiers={(DOMAIN, f"{coordinator.client.host}_{zone_id}")},
|
||||||
|
name=zone_name,
|
||||||
|
manufacturer="RDZ",
|
||||||
|
model="HMI Thermostat",
|
||||||
|
sw_version="1.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool | None:
|
||||||
|
"""Return True if the zone has the request active.
|
||||||
|
|
||||||
|
Uses the same logic as RDZZonePumpBinarySensor:
|
||||||
|
- For real thermostats: True if this zone or linked virtual zone is active
|
||||||
|
- For virtual/unconfigured: Always False
|
||||||
|
"""
|
||||||
|
zone_type = self._zone_data.get(CONF_ZONE_TYPE)
|
||||||
|
|
||||||
|
if zone_type != THERMOSTAT_TYPE_REAL:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.coordinator.data is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
request_data = self.coordinator.data.get(self._data_key, {})
|
||||||
|
|
||||||
|
# Check if this zone has the request active
|
||||||
|
if request_data.get(self._zone_id, False):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check if linked virtual zone has the request active
|
||||||
|
linked_virtual = self._zone_data.get(CONF_LINKED_VIRTUAL_ZONE)
|
||||||
|
if linked_virtual is not None and request_data.get(linked_virtual, False):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _handle_coordinator_update(self) -> None:
|
||||||
|
"""Handle updated data from the coordinator."""
|
||||||
|
# Update zone_data in case config changed
|
||||||
|
zones_config = self.coordinator.config_entry.data.get(CONF_ZONES, {})
|
||||||
|
self._zone_data = zones_config.get(str(self._zone_id), self._zone_data)
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ from .modbus_client import RDZModbusClient
|
|||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_zone_id(zone_id: int | str) -> str:
|
||||||
|
"""Format zone ID as zero-padded two-digit string for consistent ordering."""
|
||||||
|
return f"{int(zone_id):02d}"
|
||||||
|
|
||||||
|
|
||||||
class RDZHMIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
class RDZHMIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||||
"""Handle a config flow for RDZ HMI Control."""
|
"""Handle a config flow for RDZ HMI Control."""
|
||||||
|
|
||||||
@@ -68,8 +73,9 @@ class RDZHMIConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
self._abort_if_unique_id_configured()
|
self._abort_if_unique_id_configured()
|
||||||
|
|
||||||
# Build initial zone config (all unconfigured)
|
# Build initial zone config (all unconfigured)
|
||||||
|
# Use zero-padded string keys for consistent ordering
|
||||||
zones_config = {
|
zones_config = {
|
||||||
zone_id: {
|
_format_zone_id(zone_id): {
|
||||||
CONF_ZONE_ID: zone_id,
|
CONF_ZONE_ID: zone_id,
|
||||||
CONF_ZONE_NAME: f"Zone {zone_id}",
|
CONF_ZONE_NAME: f"Zone {zone_id}",
|
||||||
CONF_ZONE_TYPE: THERMOSTAT_TYPE_UNCONFIGURED,
|
CONF_ZONE_TYPE: THERMOSTAT_TYPE_UNCONFIGURED,
|
||||||
@@ -127,6 +133,8 @@ class RDZHMIOptionsFlow(config_entries.OptionsFlow):
|
|||||||
) -> FlowResult:
|
) -> FlowResult:
|
||||||
"""Handle the initial options step - select zone to configure."""
|
"""Handle the initial options step - select zone to configure."""
|
||||||
zones_config: dict = self._config_entry.data.get(CONF_ZONES, {})
|
zones_config: dict = self._config_entry.data.get(CONF_ZONES, {})
|
||||||
|
# Normalize keys to zero-padded strings for consistent ordering
|
||||||
|
zones_config = {_format_zone_id(k): v for k, v in zones_config.items()}
|
||||||
|
|
||||||
if not zones_config:
|
if not zones_config:
|
||||||
return self.async_abort(reason="no_zones")
|
return self.async_abort(reason="no_zones")
|
||||||
@@ -139,7 +147,7 @@ class RDZHMIOptionsFlow(config_entries.OptionsFlow):
|
|||||||
zone_options = []
|
zone_options = []
|
||||||
for zone_id_str, zone_data in zones_config.items():
|
for zone_id_str, zone_data in zones_config.items():
|
||||||
zone_id = int(zone_id_str) if isinstance(zone_id_str, str) else zone_id_str
|
zone_id = int(zone_id_str) if isinstance(zone_id_str, str) else zone_id_str
|
||||||
zone_name = zone_data.get(CONF_ZONE_NAME, f"Zone {zone_id}")
|
zone_name = f"[Zone {zone_id}] "+zone_data.get(CONF_ZONE_NAME, "")
|
||||||
zone_type = zone_data.get(CONF_ZONE_TYPE, THERMOSTAT_TYPE_UNCONFIGURED)
|
zone_type = zone_data.get(CONF_ZONE_TYPE, THERMOSTAT_TYPE_UNCONFIGURED)
|
||||||
type_label = {
|
type_label = {
|
||||||
THERMOSTAT_TYPE_REAL: "Real",
|
THERMOSTAT_TYPE_REAL: "Real",
|
||||||
@@ -148,7 +156,7 @@ class RDZHMIOptionsFlow(config_entries.OptionsFlow):
|
|||||||
}.get(zone_type, "Unknown")
|
}.get(zone_type, "Unknown")
|
||||||
zone_options.append(
|
zone_options.append(
|
||||||
selector.SelectOptionDict(
|
selector.SelectOptionDict(
|
||||||
value=str(zone_id),
|
value=_format_zone_id(zone_id),
|
||||||
label=f"{zone_name} ({type_label})",
|
label=f"{zone_name} ({type_label})",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -172,7 +180,9 @@ class RDZHMIOptionsFlow(config_entries.OptionsFlow):
|
|||||||
) -> FlowResult:
|
) -> FlowResult:
|
||||||
"""Handle zone configuration step."""
|
"""Handle zone configuration step."""
|
||||||
zones_config: dict = dict(self._config_entry.data.get(CONF_ZONES, {}))
|
zones_config: dict = dict(self._config_entry.data.get(CONF_ZONES, {}))
|
||||||
zone_id_str = str(self._selected_zone_id)
|
# Normalize keys to zero-padded strings for consistent ordering
|
||||||
|
zones_config = {_format_zone_id(k): v for k, v in zones_config.items()}
|
||||||
|
zone_id_str = _format_zone_id(self._selected_zone_id)
|
||||||
current_zone = zones_config.get(zone_id_str, {})
|
current_zone = zones_config.get(zone_id_str, {})
|
||||||
|
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
@@ -207,16 +217,28 @@ class RDZHMIOptionsFlow(config_entries.OptionsFlow):
|
|||||||
current_type = current_zone.get(CONF_ZONE_TYPE, THERMOSTAT_TYPE_UNCONFIGURED)
|
current_type = current_zone.get(CONF_ZONE_TYPE, THERMOSTAT_TYPE_UNCONFIGURED)
|
||||||
current_linked = current_zone.get(CONF_LINKED_VIRTUAL_ZONE)
|
current_linked = current_zone.get(CONF_LINKED_VIRTUAL_ZONE)
|
||||||
|
|
||||||
# Build virtual zone options for linking (exclude current zone)
|
# Collect virtual zones already linked to other real thermostats
|
||||||
|
already_linked_virtuals = set()
|
||||||
|
for zid_str, zone_data in zones_config.items():
|
||||||
|
zid = int(zid_str) if isinstance(zid_str, str) else zid_str
|
||||||
|
if zid != self._selected_zone_id and zone_data.get(CONF_ZONE_TYPE) == THERMOSTAT_TYPE_REAL:
|
||||||
|
linked = zone_data.get(CONF_LINKED_VIRTUAL_ZONE)
|
||||||
|
if linked is not None:
|
||||||
|
already_linked_virtuals.add(linked)
|
||||||
|
|
||||||
|
# Build virtual zone options for linking (exclude current zone and already-linked virtuals)
|
||||||
virtual_zone_options = [
|
virtual_zone_options = [
|
||||||
selector.SelectOptionDict(value="none", label="None")
|
selector.SelectOptionDict(value="none", label="None")
|
||||||
]
|
]
|
||||||
for zid_str, zone_data in zones_config.items():
|
for zid_str, zone_data in zones_config.items():
|
||||||
zid = int(zid_str) if isinstance(zid_str, str) else zid_str
|
zid = int(zid_str) if isinstance(zid_str, str) else zid_str
|
||||||
if zid != self._selected_zone_id and zone_data.get(CONF_ZONE_TYPE) == THERMOSTAT_TYPE_VIRTUAL:
|
if zid != self._selected_zone_id and zone_data.get(CONF_ZONE_TYPE) == THERMOSTAT_TYPE_VIRTUAL:
|
||||||
|
# Skip if already linked to another real thermostat
|
||||||
|
if zid in already_linked_virtuals:
|
||||||
|
continue
|
||||||
zone_name = zone_data.get(CONF_ZONE_NAME, f"Zone {zid}")
|
zone_name = zone_data.get(CONF_ZONE_NAME, f"Zone {zid}")
|
||||||
virtual_zone_options.append(
|
virtual_zone_options.append(
|
||||||
selector.SelectOptionDict(value=str(zid), label=zone_name)
|
selector.SelectOptionDict(value=_format_zone_id(zid), label=zone_name)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build schema
|
# Build schema
|
||||||
@@ -240,11 +262,12 @@ class RDZHMIOptionsFlow(config_entries.OptionsFlow):
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Only show linked virtual zone option for real thermostats with available virtual zones
|
# Show linked virtual zone option when virtual zones are available
|
||||||
if current_type == THERMOSTAT_TYPE_REAL and len(virtual_zone_options) > 1:
|
# (selection is only used if user chooses "Real" type - handled in user_input processing)
|
||||||
|
if len(virtual_zone_options) > 1:
|
||||||
schema_dict[vol.Optional(
|
schema_dict[vol.Optional(
|
||||||
CONF_LINKED_VIRTUAL_ZONE,
|
CONF_LINKED_VIRTUAL_ZONE,
|
||||||
default=str(current_linked) if current_linked else "none"
|
default=_format_zone_id(current_linked) if current_linked else "none"
|
||||||
)] = selector.SelectSelector(
|
)] = selector.SelectSelector(
|
||||||
selector.SelectSelectorConfig(
|
selector.SelectSelectorConfig(
|
||||||
options=virtual_zone_options,
|
options=virtual_zone_options,
|
||||||
|
|||||||
@@ -23,6 +23,22 @@ COIL_SEASON = 2 # 0 = winter, 1 = summer
|
|||||||
REGISTER_ACTIVITY_START = 2892
|
REGISTER_ACTIVITY_START = 2892
|
||||||
REGISTER_ACTIVITY_COUNT = 4
|
REGISTER_ACTIVITY_COUNT = 4
|
||||||
|
|
||||||
|
# Zone request bitmask registers (4 registers each, 16 bits = 64 zones)
|
||||||
|
REGISTER_HUMIDITY_REQUEST_START = 2896
|
||||||
|
REGISTER_HUMIDITY_REQUEST_COUNT = 4
|
||||||
|
|
||||||
|
REGISTER_VENTILATION_REQUEST_START = 2900
|
||||||
|
REGISTER_VENTILATION_REQUEST_COUNT = 4
|
||||||
|
|
||||||
|
REGISTER_RENEWAL_REQUEST_START = 2904
|
||||||
|
REGISTER_RENEWAL_REQUEST_COUNT = 4
|
||||||
|
|
||||||
|
REGISTER_INTEGRATION_REQUEST_START = 2908
|
||||||
|
REGISTER_INTEGRATION_REQUEST_COUNT = 4
|
||||||
|
|
||||||
|
REGISTER_DEHUMIDIFICATION_PUMP_START = 2912
|
||||||
|
REGISTER_DEHUMIDIFICATION_PUMP_COUNT = 4
|
||||||
|
|
||||||
# Humidity registers
|
# Humidity registers
|
||||||
REGISTER_HUMIDITY_START = 7701
|
REGISTER_HUMIDITY_START = 7701
|
||||||
REGISTER_HUMIDITY_COUNT = 64
|
REGISTER_HUMIDITY_COUNT = 64
|
||||||
@@ -75,6 +91,11 @@ DATA_WINTER_SETPOINTS = "winter_setpoints"
|
|||||||
DATA_SUMMER_SETPOINTS = "summer_setpoints"
|
DATA_SUMMER_SETPOINTS = "summer_setpoints"
|
||||||
DATA_SEASON = "season" # True = summer, False = winter
|
DATA_SEASON = "season" # True = summer, False = winter
|
||||||
DATA_ZONE_ACTIVITY = "zone_activity" # dict[int, bool] - zone_id -> is_active
|
DATA_ZONE_ACTIVITY = "zone_activity" # dict[int, bool] - zone_id -> is_active
|
||||||
|
DATA_HUMIDITY_REQUEST = "humidity_request"
|
||||||
|
DATA_VENTILATION_REQUEST = "ventilation_request"
|
||||||
|
DATA_RENEWAL_REQUEST = "renewal_request"
|
||||||
|
DATA_INTEGRATION_REQUEST = "integration_request"
|
||||||
|
DATA_DEHUMIDIFICATION_PUMP = "dehumidification_pump"
|
||||||
DATA_HUMIDITY = "humidity"
|
DATA_HUMIDITY = "humidity"
|
||||||
DATA_DEHUMIDIFICATION_SETPOINTS = "dehumidification_setpoints"
|
DATA_DEHUMIDIFICATION_SETPOINTS = "dehumidification_setpoints"
|
||||||
DATA_DEW_POINTS = "dew_points"
|
DATA_DEW_POINTS = "dew_points"
|
||||||
|
|||||||
@@ -18,17 +18,22 @@ from .const import (
|
|||||||
CONF_ZONE_TYPE,
|
CONF_ZONE_TYPE,
|
||||||
CONF_ZONES,
|
CONF_ZONES,
|
||||||
DATA_CALCULATED_WATER_TEMPS,
|
DATA_CALCULATED_WATER_TEMPS,
|
||||||
|
DATA_DEHUMIDIFICATION_PUMP,
|
||||||
DATA_DEHUMIDIFICATION_SETPOINTS,
|
DATA_DEHUMIDIFICATION_SETPOINTS,
|
||||||
DATA_DELIVERY_WATER_TEMPS,
|
DATA_DELIVERY_WATER_TEMPS,
|
||||||
DATA_DEW_POINTS,
|
DATA_DEW_POINTS,
|
||||||
DATA_HUMIDITY,
|
DATA_HUMIDITY,
|
||||||
|
DATA_HUMIDITY_REQUEST,
|
||||||
|
DATA_INTEGRATION_REQUEST,
|
||||||
DATA_OUTSIDE_TEMP,
|
DATA_OUTSIDE_TEMP,
|
||||||
DATA_PUMP_ACTIVE,
|
DATA_PUMP_ACTIVE,
|
||||||
|
DATA_RENEWAL_REQUEST,
|
||||||
DATA_SEASON,
|
DATA_SEASON,
|
||||||
DATA_SUMMER_SETPOINTS,
|
DATA_SUMMER_SETPOINTS,
|
||||||
DATA_SYSTEM_ACTIVATION,
|
DATA_SYSTEM_ACTIVATION,
|
||||||
DATA_TEMPERATURES,
|
DATA_TEMPERATURES,
|
||||||
DATA_TIME_SETTINGS,
|
DATA_TIME_SETTINGS,
|
||||||
|
DATA_VENTILATION_REQUEST,
|
||||||
DATA_WINTER_SETPOINTS,
|
DATA_WINTER_SETPOINTS,
|
||||||
DATA_ZONE_ACTIVITY,
|
DATA_ZONE_ACTIVITY,
|
||||||
DATA_ZONE_MODES,
|
DATA_ZONE_MODES,
|
||||||
@@ -93,6 +98,13 @@ class RDZDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
calculated_water_temps = await self.client.read_calculated_water_temps()
|
calculated_water_temps = await self.client.read_calculated_water_temps()
|
||||||
pump_active = await self.client.read_pump_active()
|
pump_active = await self.client.read_pump_active()
|
||||||
|
|
||||||
|
# Read zone request bitmasks
|
||||||
|
humidity_request = await self.client.read_humidity_request()
|
||||||
|
ventilation_request = await self.client.read_ventilation_request()
|
||||||
|
renewal_request = await self.client.read_renewal_request()
|
||||||
|
integration_request = await self.client.read_integration_request()
|
||||||
|
dehumidification_pump = await self.client.read_dehumidification_pump()
|
||||||
|
|
||||||
if temperatures is None or winter_setpoints is None or summer_setpoints is None:
|
if temperatures is None or winter_setpoints is None or summer_setpoints is None:
|
||||||
raise UpdateFailed("Failed to read data from Modbus device")
|
raise UpdateFailed("Failed to read data from Modbus device")
|
||||||
|
|
||||||
@@ -119,6 +131,12 @@ class RDZDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
DATA_DELIVERY_WATER_TEMPS: delivery_water_temps,
|
DATA_DELIVERY_WATER_TEMPS: delivery_water_temps,
|
||||||
DATA_CALCULATED_WATER_TEMPS: calculated_water_temps,
|
DATA_CALCULATED_WATER_TEMPS: calculated_water_temps,
|
||||||
DATA_PUMP_ACTIVE: pump_active,
|
DATA_PUMP_ACTIVE: pump_active,
|
||||||
|
# Zone request bitmasks
|
||||||
|
DATA_HUMIDITY_REQUEST: humidity_request,
|
||||||
|
DATA_VENTILATION_REQUEST: ventilation_request,
|
||||||
|
DATA_RENEWAL_REQUEST: renewal_request,
|
||||||
|
DATA_INTEGRATION_REQUEST: integration_request,
|
||||||
|
DATA_DEHUMIDIFICATION_PUMP: dehumidification_pump,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
|
|||||||
@@ -8,5 +8,5 @@
|
|||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"requirements": ["pymodbus>=3.6.0"],
|
"requirements": ["pymodbus>=3.6.0"],
|
||||||
"version": "1.0.0"
|
"version": "1.1.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ from .const import (
|
|||||||
REGISTER_ACTIVITY_START,
|
REGISTER_ACTIVITY_START,
|
||||||
REGISTER_CALCULATED_WATER_TEMP_COUNT,
|
REGISTER_CALCULATED_WATER_TEMP_COUNT,
|
||||||
REGISTER_CALCULATED_WATER_TEMP_START,
|
REGISTER_CALCULATED_WATER_TEMP_START,
|
||||||
|
REGISTER_DEHUMIDIFICATION_PUMP_COUNT,
|
||||||
|
REGISTER_DEHUMIDIFICATION_PUMP_START,
|
||||||
REGISTER_DEHUMIDIFICATION_SETPOINT_COUNT,
|
REGISTER_DEHUMIDIFICATION_SETPOINT_COUNT,
|
||||||
REGISTER_DEHUMIDIFICATION_SETPOINT_START,
|
REGISTER_DEHUMIDIFICATION_SETPOINT_START,
|
||||||
REGISTER_DELIVERY_WATER_TEMP_COUNT,
|
REGISTER_DELIVERY_WATER_TEMP_COUNT,
|
||||||
@@ -21,9 +23,15 @@ from .const import (
|
|||||||
REGISTER_DEW_POINT_COUNT,
|
REGISTER_DEW_POINT_COUNT,
|
||||||
REGISTER_DEW_POINT_START,
|
REGISTER_DEW_POINT_START,
|
||||||
REGISTER_HUMIDITY_COUNT,
|
REGISTER_HUMIDITY_COUNT,
|
||||||
|
REGISTER_HUMIDITY_REQUEST_COUNT,
|
||||||
|
REGISTER_HUMIDITY_REQUEST_START,
|
||||||
REGISTER_HUMIDITY_START,
|
REGISTER_HUMIDITY_START,
|
||||||
|
REGISTER_INTEGRATION_REQUEST_COUNT,
|
||||||
|
REGISTER_INTEGRATION_REQUEST_START,
|
||||||
REGISTER_OUTSIDE_TEMP,
|
REGISTER_OUTSIDE_TEMP,
|
||||||
REGISTER_PUMP_ACTIVE,
|
REGISTER_PUMP_ACTIVE,
|
||||||
|
REGISTER_RENEWAL_REQUEST_COUNT,
|
||||||
|
REGISTER_RENEWAL_REQUEST_START,
|
||||||
REGISTER_TEMP_START,
|
REGISTER_TEMP_START,
|
||||||
REGISTER_TEMP_COUNT,
|
REGISTER_TEMP_COUNT,
|
||||||
REGISTER_TIME_DAY,
|
REGISTER_TIME_DAY,
|
||||||
@@ -31,6 +39,8 @@ from .const import (
|
|||||||
REGISTER_TIME_MINUTE,
|
REGISTER_TIME_MINUTE,
|
||||||
REGISTER_TIME_MONTH,
|
REGISTER_TIME_MONTH,
|
||||||
REGISTER_TIME_YEAR,
|
REGISTER_TIME_YEAR,
|
||||||
|
REGISTER_VENTILATION_REQUEST_COUNT,
|
||||||
|
REGISTER_VENTILATION_REQUEST_START,
|
||||||
REGISTER_WINTER_SETPOINT_START,
|
REGISTER_WINTER_SETPOINT_START,
|
||||||
REGISTER_WINTER_SETPOINT_COUNT,
|
REGISTER_WINTER_SETPOINT_COUNT,
|
||||||
REGISTER_SUMMER_SETPOINT_START,
|
REGISTER_SUMMER_SETPOINT_START,
|
||||||
@@ -468,3 +478,67 @@ class RDZModbusClient:
|
|||||||
return None
|
return None
|
||||||
bitmask = registers[0]
|
bitmask = registers[0]
|
||||||
return {i + 1: bool(bitmask & (1 << i)) for i in range(8)}
|
return {i + 1: bool(bitmask & (1 << i)) for i in range(8)}
|
||||||
|
|
||||||
|
async def _read_zone_bitmask(self, start_register: int, count: int) -> dict[int, bool] | None:
|
||||||
|
"""Generic zone bitmask reader for 64 zones across 4 registers.
|
||||||
|
|
||||||
|
Each register contains 16 bits, with bit 0 of register 0 being zone 0,
|
||||||
|
bit 15 of register 0 being zone 15, bit 0 of register 1 being zone 16, etc.
|
||||||
|
|
||||||
|
Returns dict of zone_id (0-63) -> is_active (True/False).
|
||||||
|
"""
|
||||||
|
registers = await self.read_registers(start_register, count)
|
||||||
|
if registers is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
result: dict[int, bool] = {}
|
||||||
|
for reg_idx, reg_value in enumerate(registers):
|
||||||
|
for bit in range(16):
|
||||||
|
zone_id = reg_idx * 16 + bit
|
||||||
|
result[zone_id] = bool(reg_value & (1 << bit))
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def read_humidity_request(self) -> dict[int, bool] | None:
|
||||||
|
"""Read humidity request bitmasks (registers 2896-2899).
|
||||||
|
|
||||||
|
Returns dict of zone_id (0-63) -> is_requesting (True/False).
|
||||||
|
"""
|
||||||
|
return await self._read_zone_bitmask(
|
||||||
|
REGISTER_HUMIDITY_REQUEST_START, REGISTER_HUMIDITY_REQUEST_COUNT
|
||||||
|
)
|
||||||
|
|
||||||
|
async def read_ventilation_request(self) -> dict[int, bool] | None:
|
||||||
|
"""Read ventilation request bitmasks (registers 2900-2903).
|
||||||
|
|
||||||
|
Returns dict of zone_id (0-63) -> is_requesting (True/False).
|
||||||
|
"""
|
||||||
|
return await self._read_zone_bitmask(
|
||||||
|
REGISTER_VENTILATION_REQUEST_START, REGISTER_VENTILATION_REQUEST_COUNT
|
||||||
|
)
|
||||||
|
|
||||||
|
async def read_renewal_request(self) -> dict[int, bool] | None:
|
||||||
|
"""Read renewal request bitmasks (registers 2904-2907).
|
||||||
|
|
||||||
|
Returns dict of zone_id (0-63) -> is_requesting (True/False).
|
||||||
|
"""
|
||||||
|
return await self._read_zone_bitmask(
|
||||||
|
REGISTER_RENEWAL_REQUEST_START, REGISTER_RENEWAL_REQUEST_COUNT
|
||||||
|
)
|
||||||
|
|
||||||
|
async def read_integration_request(self) -> dict[int, bool] | None:
|
||||||
|
"""Read integration request bitmasks (registers 2908-2911).
|
||||||
|
|
||||||
|
Returns dict of zone_id (0-63) -> is_requesting (True/False).
|
||||||
|
"""
|
||||||
|
return await self._read_zone_bitmask(
|
||||||
|
REGISTER_INTEGRATION_REQUEST_START, REGISTER_INTEGRATION_REQUEST_COUNT
|
||||||
|
)
|
||||||
|
|
||||||
|
async def read_dehumidification_pump(self) -> dict[int, bool] | None:
|
||||||
|
"""Read dehumidification pump bitmasks (registers 2912-2915).
|
||||||
|
|
||||||
|
Returns dict of zone_id (0-63) -> is_active (True/False).
|
||||||
|
"""
|
||||||
|
return await self._read_zone_bitmask(
|
||||||
|
REGISTER_DEHUMIDIFICATION_PUMP_START, REGISTER_DEHUMIDIFICATION_PUMP_COUNT
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user