Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
c338f29
refactor models, init, BRDG, add VMD-base, const OffOnMode, U8Register
silverailscolo Sep 24, 2025
94befdb
fix registers, no shadowing, CLI entry to int
silverailscolo Sep 24, 2025
d50d939
refactor CLI
silverailscolo Sep 24, 2025
9249c0b
Ventura RegisterAccess like existing models
silverailscolo Sep 24, 2025
7f64cf8
lint line too long
silverailscolo Sep 24, 2025
8f65e39
assert mod not None
silverailscolo Sep 24, 2025
3b95a08
productId is int
silverailscolo Sep 24, 2025
d15b99a
VMDPresetFansSpeeds field default
silverailscolo Sep 24, 2025
a5b9c73
unnec. comprehension
silverailscolo Sep 24, 2025
cbc4a76
BRDG None raises Ex
silverailscolo Sep 24, 2025
924811d
more BRDG None checks
silverailscolo Sep 24, 2025
1568da5
even more None checks
silverailscolo Sep 24, 2025
dbca4f2
fix supply_temp register
silverailscolo Sep 30, 2025
e518c3a
Add U8Register, clamp to UINT8 range
Sep 22, 2025
055aa1c
Clamp register value to datatype range on writes
Sep 22, 2025
c77655f
Add U8Register, clamp to UINT8 range
Sep 22, 2025
8f85f79
apply comments
silverailscolo Oct 1, 2025
1ffa5e2
add 3 util methods
silverailscolo Oct 2, 2025
d932856
api access to airios_models etc
silverailscolo Oct 2, 2025
c1328d1
create brdg_base.py, move models function there from brdg_02r13 (must…
silverailscolo Oct 3, 2025
a24bb94
brdg_base print to cli
silverailscolo Oct 3, 2025
a8a09ec
init without brdg_data[models] etc
silverailscolo Oct 4, 2025
f58a0cc
remove brdg_data[models] etc, fix AiriosData
silverailscolo Oct 4, 2025
0701045
tweak init.fetch AiriosData type
silverailscolo Oct 4, 2025
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
405 changes: 250 additions & 155 deletions cli.py

Large diffs are not rendered by default.

36 changes: 20 additions & 16 deletions src/pyairios/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
"""The Airios RF bridge API entrypoint."""

import logging

from pyairios.models.brdg_02r13 import BRDG02R13
from pyairios.models.brdg_02r13 import DEFAULT_SLAVE_ID as BRDG02R13_DEFAULT_SLAVE_ID
from pyairios.models.vmd_02rps78 import VMD02RPS78
from pyairios.models.vmn_05lm02 import VMN05LM02

from .client import (
AiriosBaseTransport,
Expand All @@ -13,11 +13,13 @@
AsyncAiriosModbusRtuClient,
AsyncAiriosModbusTcpClient,
)
from .constants import BindingStatus, ProductId
from .constants import BindingStatus
from .data_model import AiriosBoundNodeInfo, AiriosData, AiriosNodeData
from .exceptions import AiriosException
from .node import AiriosNode

LOGGER = logging.getLogger(__name__)


class Airios:
"""The Airios RF bridge API."""
Expand Down Expand Up @@ -54,7 +56,7 @@ async def bind_status(self) -> BindingStatus:
async def bind_controller(
self,
slave_id: int,
product_id: ProductId,
product_id: int,
product_serial: int | None = None,
) -> bool:
"""Bind a new controller to the bridge."""
Expand All @@ -64,7 +66,7 @@ async def bind_accessory(
self,
controller_slave_id: int,
slave_id: int,
product_id: ProductId,
product_id: int,
) -> bool:
"""Bind a new accessory to the bridge."""
return await self.bridge.bind_accessory(controller_slave_id, slave_id, product_id)
Expand All @@ -77,21 +79,23 @@ async def fetch(self) -> AiriosData:
"""Get the data from all nodes at once."""
data: dict[int, AiriosNodeData] = {}

brdg_data = await self.bridge.fetch_bridge()
if brdg_data["rf_address"] is None:
brdg_data = await self.bridge.fetch_bridge_data()
if brdg_data is None or brdg_data["rf_address"] is None:
raise AiriosException("Failed to fetch node RF address")
bridge_rf_address = brdg_data["rf_address"].value
data[self.bridge.slave_id] = brdg_data

for node in await self.bridge.nodes():
if node.product_id == ProductId.VMD_02RPS78:
vmd = VMD02RPS78(node.slave_id, self.bridge.client)
vmd_data = await vmd.fetch_vmd_data()
data[node.slave_id] = vmd_data
if node.product_id == ProductId.VMN_05LM02:
vmn = VMN05LM02(node.slave_id, self.bridge.client)
vmn_data = await vmn.fetch_vmn_data()
data[node.slave_id] = vmn_data
prids = brdg_data["product_ids"]
if prids is not None and brdg_data["models"] is not None:
for _node in await self.bridge.nodes():
for key, _id in prids.items():
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs to be reversed. Instead of iterating the supported models first, iterate the bound nodes and then check if it is supported or not (iterating prids). The reason is that you can have several physical machines bound, all of them with the same product id but with its own virtual modbus device on the bridge.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If reversed, it means awaiting every loop. As is, we await just once, and fetch a matching model (might be the same as in previous loop): same result

if _id == _node.product_id and brdg_data["models"][key] is not None:
LOGGER.debug("fetch_node_data for key: %s", key)
node_module = brdg_data["models"][key].Node(
_node.slave_id, self.bridge.client
)
node_data = await node_module.fetch_node_data()
data[_node.slave_id] = node_data
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would be nice to print a warning if an unsupported device is bound and returned in "self.bridge.nodes()"


return AiriosData(bridge_rf_address=bridge_rf_address, nodes=data)

Expand Down
2 changes: 1 addition & 1 deletion src/pyairios/client.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Async client for the Airios BRDB-02R13 Modbus gateway."""
"""Async client for the Airios BRDG-02R13 Modbus gateway."""

from __future__ import annotations

Expand Down
80 changes: 59 additions & 21 deletions src/pyairios/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,18 @@


class ProductId(IntEnum):
"""The product ID is a unique product identifier.

The value is composed by three fields, product type + sub ID + manufacturer ID.
"""
The product ID is a unique product identifier.
The value is composed of three fields: product type + sub ID + manufacturer ID.
Replaced by pr_id dynamic dict, created during _init_ in BRDG
"""

BRDG_02R13 = 0x0001C849
VMD_02RPS78 = 0x0001C892
VMN_05LM02 = 0x0001C83E
VMN_02LM11 = 0x0001C852
VMD_07RPS13 = 0x0001C883 # ClimaRad VenturaV1X

def __str__(self) -> str:
if self.value == self.BRDG_02R13:
return "BRDG-02R13"
if self.value == self.VMD_02RPS78:
return "VMD-02RPS78"
if self.value == self.VMN_05LM02:
return "VMN-05LM02"
if self.value == self.VMN_02LM11:
return "VMN-02LM11"
if self.value == self.VMD_07RPS13:
return "VMD-07RPS13"
raise ValueError(f"Unknown product ID value {self.value}")
# this info was moved to the models/ class files as pr_id
# get the dict from bridge by calling bridge.product_ids
# they will be unique as long as all files are in flat models/ dir
# new definitions will be picked up automatically when dropped there
# only Bridge product_id required as const, used during api init
BRDG_02R13 = 0x0001C849 # RF Bridge


class BoundStatus(IntEnum):
Expand Down Expand Up @@ -368,6 +357,7 @@ class VMDHeater:
class VMDCapabilities(Flag):
"""Ventilation unit capabilities."""

NO_CAPABLE = 0x0000
PRE_HEATER_AVAILABLE = 0x0001
POST_HEATER_AVAILABLE = 0x0002
RESERVED = 0x0004
Expand All @@ -393,6 +383,53 @@ class VMDFaultStatus(IntEnum):
FAN_FAILURE = 1


class VMDOffOnMode(IntEnum):
"""General use binary state."""

OFF = 0
ON = 1
UNKNOWN = 10 # not in specs


class VMDVentilationMode(IntEnum):
"""Ventilation unit (Ventura) mode preset."""

OFF = 0
PAUSE = 1
ON = 2
OVERRIDE_1 = 3
OVERRIDE_2 = 4
OVERRIDE_3 = 5
OVERRIDE_4 = 6
OVERRIDE_5 = 7
SERVICE = 8
RETYPE = 9
UNKNOWN = 10 # not in specs

def __str__(self) -> str: # pylint: disable=too-many-return-statements
if self.value == self.OFF:
return "Off"
if self.value == self.PAUSE:
return "Pause"
if self.value == self.ON:
return "On/Auto"
if self.value == self.OVERRIDE_1:
return "I (temporary override)"
if self.value == self.OVERRIDE_2:
return "II (temporary override)"
if self.value == self.OVERRIDE_3:
return "III (temporary override)"
if self.value == self.OVERRIDE_4:
return "IV (temporary override)"
if self.value == self.OVERRIDE_5:
return "V (temporary override)"
if self.value == self.SERVICE:
return "Service Mode"
if self.value == self.RETYPE:
return "Retype (see manual)"
raise ValueError(f"Unknown ventilation mode value {self.value}")


class VMDVentilationSpeed(IntEnum):
"""Ventilation unit speed preset."""

Expand Down Expand Up @@ -590,5 +627,6 @@ def parse(cls, value: str):
class VMDBypassPosition:
"""VMD bypass position sample."""

# Ventura bp_position: 0 = closed, 100 = open
position: int
error: bool
62 changes: 10 additions & 52 deletions src/pyairios/data_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,15 @@

from dataclasses import dataclass
from datetime import timedelta
from types import ModuleType
from typing import TypedDict

from pyairios.constants import (
BatteryStatus,
BoundStatus,
FaultStatus,
ProductId,
RFCommStatus,
ValueErrorStatus,
VMDBypassMode,
VMDBypassPosition,
VMDErrorCode,
VMDHeater,
VMDRequestedVentilationSpeed,
VMDTemperature,
VMDVentilationSpeed,
)
from pyairios.registers import Result

Expand All @@ -27,7 +20,7 @@ class AiriosBoundNodeInfo:
"""Bridge bound node information."""

slave_id: int
product_id: ProductId
product_id: int
rf_address: int


Expand All @@ -36,7 +29,7 @@ class AiriosNodeData(TypedDict):

slave_id: int
rf_address: Result[int] | None
product_id: Result[ProductId] | None
product_id: Result[int] | None
product_name: Result[str] | None
sw_version: Result[int] | None
rf_comm_status: Result[RFCommStatus] | None
Expand All @@ -51,41 +44,8 @@ class AiriosDeviceData(AiriosNodeData):
value_error_status: Result[ValueErrorStatus] | None


class VMD02RPS78Data(AiriosDeviceData):
"""VMD-02RPS78 node data."""

error_code: Result[VMDErrorCode] | None
ventilation_speed: Result[VMDVentilationSpeed] | None
override_remaining_time: Result[int] | None
exhaust_fan_speed: Result[int] | None
supply_fan_speed: Result[int] | None
exhaust_fan_rpm: Result[int] | None
supply_fan_rpm: Result[int] | None
indoor_air_temperature: Result[VMDTemperature] | None
outdoor_air_temperature: Result[VMDTemperature] | None
exhaust_air_temperature: Result[VMDTemperature] | None
supply_air_temperature: Result[VMDTemperature] | None
filter_dirty: Result[int] | None
filter_remaining_percent: Result[int] | None
filter_duration_days: Result[int] | None
bypass_position: Result[VMDBypassPosition] | None
bypass_mode: Result[VMDBypassMode] | None
bypass_status: Result[int] | None
defrost: Result[int] | None
preheater: Result[VMDHeater] | None
postheater: Result[VMDHeater] | None
preheater_setpoint: Result[float] | None
free_ventilation_setpoint: Result[float] | None
free_ventilation_cooling_offset: Result[float] | None
frost_protection_preheater_setpoint: Result[float] | None
preset_high_fan_speed_supply: Result[int] | None
preset_high_fan_speed_exhaust: Result[int] | None
preset_medium_fan_speed_supply: Result[int] | None
preset_medium_fan_speed_exhaust: Result[int] | None
preset_low_fan_speed_supply: Result[int] | None
preset_low_fan_speed_exhaust: Result[int] | None
preset_standby_fan_speed_supply: Result[int] | None
preset_standby_fan_speed_exhaust: Result[int] | None
# Here only data_models for special devices. To simplify adding new models,
# 'normal' node data_models, all named Data, are in their respective models/module file


class BRDG02R13Data(AiriosNodeData):
Expand All @@ -96,17 +56,15 @@ class BRDG02R13Data(AiriosNodeData):
rf_load_last_hour: Result[float] | None
rf_load_current_hour: Result[float] | None
power_on_time: Result[timedelta] | None


class VMN05LM02Data(AiriosDeviceData):
"""VMN-05LM02 node data."""

requested_ventilation_speed: Result[VMDRequestedVentilationSpeed] | None
# Bridge holds info collected from models/ definition at startup:
models: dict[str, ModuleType] | None
model_descriptions: dict[str, str] | None
product_ids: dict[str, int] | None


@dataclass
class AiriosData:
"""Data from all bridge bound nodes."""

bridge_rf_address: int
nodes: dict[int, AiriosNodeData]
nodes: dict[int, AiriosNodeData | BRDG02R13Data]
25 changes: 15 additions & 10 deletions src/pyairios/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@

from __future__ import annotations

import re
from typing import List

from .client import AsyncAiriosModbusClient
from .constants import BoundStatus, ValueErrorStatus
from .data_model import AiriosDeviceData
from .node import AiriosNode
from .node import AiriosNode, _safe_fetch
from .registers import (
I16Register,
RegisterAccess,
Expand Down Expand Up @@ -41,6 +42,10 @@ def __init__(self, slave_id: int, client: AsyncAiriosModbusClient) -> None:
]
self._add_registers(dev_registers)

def __str__(self) -> str:
prompt = str(re.sub(r"_", "-", self.__module__.upper()))
return f"{prompt}@{self.slave_id}"

async def device_bound_status(self) -> Result[BoundStatus]:
"""Get the device bound status."""
result = await self.client.get_register(self.regmap[Reg.BOUND_STATUS], self.slave_id)
Expand All @@ -56,13 +61,13 @@ async def fetch_device(self) -> AiriosDeviceData: # pylint: disable=duplicate-c

return AiriosDeviceData(
slave_id=self.slave_id,
rf_address=await self._safe_fetch(self.node_rf_address),
product_id=await self._safe_fetch(self.node_product_id),
sw_version=await self._safe_fetch(self.node_software_version),
product_name=await self._safe_fetch(self.node_product_name),
rf_comm_status=await self._safe_fetch(self.node_rf_comm_status),
battery_status=await self._safe_fetch(self.node_battery_status),
fault_status=await self._safe_fetch(self.node_fault_status),
bound_status=await self._safe_fetch(self.device_bound_status),
value_error_status=await self._safe_fetch(self.device_value_error_status),
rf_address=await _safe_fetch(self.node_rf_address),
product_id=await _safe_fetch(self.node_product_id),
sw_version=await _safe_fetch(self.node_software_version),
product_name=await _safe_fetch(self.node_product_name),
rf_comm_status=await _safe_fetch(self.node_rf_comm_status),
battery_status=await _safe_fetch(self.node_battery_status),
fault_status=await _safe_fetch(self.node_fault_status),
bound_status=await _safe_fetch(self.device_bound_status),
value_error_status=await _safe_fetch(self.device_value_error_status),
)
Loading