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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,27 @@ For all entity definitions:
- E.g., `sum_scale: [1, 10000]` for two registers starting at `address: 5` uses r1=5, r2=6, calculating r1 * 1 + r2 * 10000.
- `shift_bits`: Bit shift right (integer).
- `bits`: Bit mask length (integer).
- On a **writable** entity (`control: number`, `control: select` or `control: switch`),
`bits` and `shift_bits` make the entity address a *bit field*: the write becomes a
read-modify-write, so the other bits of the register keep their values. The read and the
write are issued under the client lock, so a poll cannot interleave between them.
- This lets several independent controls share one register. E.g. two switches in register 0,
one on bit 1 and one on bit 2, where toggling either leaves the other untouched:
```yaml
heating:
address: 0
bits: 1
shift_bits: 1
control: switch
hot_water:
address: 0
bits: 1
shift_bits: 2
control: switch
```
- `signed` and `sum_scale` are rejected on a *writable* bit field — neither has a meaningful
inverse when merging a value back into part of a register. They remain valid on read-only
entities.
- `multiplier`: Scaling factor (float).
- `offset`: Adds an offset (float).
- **Display**:
Expand Down
90 changes: 81 additions & 9 deletions custom_components/modbus_local_gateway/conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,27 +200,99 @@ def _apply_conversion_operations(

return num

def _convert_from_decimal(
self, num: float, desc: ModbusEntityDescription
) -> list[int]:
"""Convert from a decimal to registers"""
def _descale(self, num: float, desc: ModbusEntityDescription) -> int:
"""Reverse multiplier/offset and round to the raw register value.

The inverse of `_apply_conversion_operations`.
"""
if desc.conv_offset:
num -= desc.conv_offset
if desc.conv_multiplier is not None:
num = num / desc.conv_multiplier
if desc.conv_bits:
raise NotSupportedError("Setting of bit fields is not supported")
if desc.conv_shift_bits:
raise NotSupportedError("Setting of bit fields is not supported")
return int(round(num))

def field_geometry(self, desc: ModbusEntityDescription) -> tuple[int, int]:
"""Return (shift, mask) for the bit field described by `desc`.

The geometry comes from `desc.conv_shift_bits` and `desc.conv_bits` -
the `shift_bits` and `bits` keys of a device YAML. With no `conv_bits`
the field runs from the shift to the top of the register span, which is
how the read path already treats it.
"""
span: int = 16 * (desc.register_count or 1)
shift: int = desc.conv_shift_bits or 0
width: int = desc.conv_bits if desc.conv_bits is not None else span - shift
return shift, (1 << width) - 1

def _convert_from_decimal(
self, num: float, desc: ModbusEntityDescription
) -> list[int]:
"""Convert from a decimal to registers"""
if desc.conv_bits or desc.conv_shift_bits:
raise NotSupportedError(
"Bit fields must be written with merge_into_registers"
)
if desc.conv_sum_scale:
raise NotSupportedError("Setting of scaled sums is not supported")

registers: list[int] = self.client.convert_to_registers(
int(round(num)),
self._descale(num, desc),
data_type=self._get_number_data_type(desc),
)
return registers

def merge_into_registers(
self,
desc: ModbusEntityDescription,
value: float,
current_registers: list[int],
) -> list[int]:
"""Merge a bit-field value into the register(s) currently on the device.

Writing a field declared with `bits` / `shift_bits` means reading the
whole register, replacing that field and writing it back. FC 0x16 (Mask
Write Register) would do this on the device, but it is optional and
cannot span a multi-register field.

The caller must hold the client lock across the read and the write.
"""
if desc.conv_sum_scale:
raise NotSupportedError("Setting of scaled sums is not supported")

data_type = self._get_number_data_type(desc)
# Same _swap_registers call is used here and on the return: it is its own
# inverse, so it un-swaps into native order here and re-swaps back into
# device order below.
raw = self.client.convert_from_registers(
self._swap_registers(current_registers, desc), data_type=data_type
)
if not isinstance(raw, int):
raise InvalidDataTypeError(
f"Invalid data type for bit field merge: {type(raw).__name__}"
)

shift, mask = self.field_geometry(desc)
field: int = self._descale(value, desc)
if not 0 <= field <= mask:
raise ValueError(
f"Value {value} maps to {field}, which does not fit the "
f"{mask.bit_length()}-bit field at shift {shift} of {desc.key}"
)

merged: int = (raw & ~(mask << shift)) | (field << shift)
_LOGGER.debug(
"Merging %s into %s: 0x%04X -> 0x%04X (mask 0x%X << %d)",
field,
desc.key,
raw,
merged,
mask,
shift,
)
return self._swap_registers(
self.client.convert_to_registers(merged, data_type=data_type), desc
)

def convert_from_response(
self, desc: ModbusEntityDescription, response: ModbusPDU
) -> str | float | int | bool | None:
Expand Down
74 changes: 74 additions & 0 deletions custom_components/modbus_local_gateway/entity_management/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,80 @@ def validate(self) -> bool:
return False
if not self._validate_scan_interval():
return False
if not self._validate_bitfield():
return False
return True

def _validate_bitfield(self) -> bool:
"""Check constraints for writable bit fields.

The merge assumes an unsigned value: a signed field has no well-defined
representation once masked into part of a register.

The geometry must also fit the register span, and a coil is already a
single bit so the options mean nothing there.

Limited to the number, switch and select controls - applying it to
sensors would stop already-working entities from being created.
"""
if self.conv_bits is None and self.conv_shift_bits is None:
return True
if self.control_type not in (
ControlType.NUMBER,
ControlType.SWITCH,
ControlType.SELECT,
):
return True

if self.is_signed:
_LOGGER.warning(
"Unable to create entity for %s: %s cannot be combined with "
"%s or %s on a writable entity",
self.key,
IS_SIGNED,
CONV_BITS,
CONV_SHIFT_BITS,
)
return False
if self.conv_sum_scale:
_LOGGER.warning(
"Unable to create entity for %s: %s cannot be combined with "
"%s or %s on a writable entity",
self.key,
CONV_SUM_SCALE,
CONV_BITS,
CONV_SHIFT_BITS,
)
return False
return self._validate_bitfield_geometry()

def _validate_bitfield_geometry(self) -> bool:
"""The field must be a real run of bits inside the registers it names."""
if self.data_type == ModbusDataType.COIL:
_LOGGER.warning(
"Unable to create entity for %s: %s and %s have no meaning on a "
"coil, which is already a single bit",
self.key,
CONV_BITS,
CONV_SHIFT_BITS,
)
return False

span: int = 16 * (self.register_count or 1)
shift: int = self.conv_shift_bits or 0
width: int = self.conv_bits if self.conv_bits is not None else span - shift
if shift < 0 or width <= 0 or shift + width > span:
_LOGGER.warning(
"Unable to create entity for %s: %s %s / %s %s does not fit the "
"%s bits it addresses",
self.key,
CONV_SHIFT_BITS,
shift,
CONV_BITS,
width,
span,
)
return False
return True

def _validate_scan_interval(self) -> bool:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -345,10 +345,6 @@ def _handle_switch_description(
) -> None | type[ModbusSwitchEntityDescription]:
"""Handle switch description specific logic"""
switch_data = _data.get("switch", {})
if _data.get(CONV_BITS) or _data.get(CONV_SHIFT_BITS):
_LOGGER.warning("bits / shift bits cannot be set for Switches")
return None

if not isinstance(switch_data, dict):
_LOGGER.warning(
"Switch configuration for %s should be a dictionary", entity
Expand Down
41 changes: 38 additions & 3 deletions custom_components/modbus_local_gateway/tcp_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,32 @@ async def _write_registers_individually(
return
_LOGGER.debug("All individual writes successful using fallback")

async def _read_current_registers(self, entity: ModbusContext) -> list[int]:
"""Read the register(s) backing a bit field, for a read-modify-write.

Must be called with the client lock held, so the read and the write it
feeds cannot be interleaved with a poll.

The span is read in one transaction rather than in `max_register_read`
chunks: a field split across two reads could tear if the device changed
in between. A failed read raises, abandoning the write - merging onto a
guess would clear the field's neighbours.
"""
response: ModbusPDU | None = await self.read_data(
func=self.read_holding_registers,
address=entity.desc.register_address,
count=entity.desc.register_count,
device_id=entity.device_id,
max_read_size=entity.desc.register_count,
)
if response is None or response.isError():
raise ModbusException(
"Unable to read current value of "
f"{entity.desc.key} at {entity.desc.register_address} - "
"aborting bit field write"
)
return response.registers

async def write_data(self, entity: ModbusContext, value: Any) -> ModbusPDU | None:
"""Writes data to Holding Registers or Coils"""
pdu: ModbusPDU | None = None
Expand All @@ -236,9 +262,18 @@ async def write_data(self, entity: ModbusContext, value: Any) -> ModbusPDU | Non
)

if entity.desc.data_type == ModbusDataType.HOLDING_REGISTER:
registers = Conversion(type(self)).convert_to_registers(
entity.desc, value
)
conversion = Conversion(type(self))
if entity.desc.conv_bits or entity.desc.conv_shift_bits:
# No dependable device-side bit write (FC 0x16 is optional):
# read the register, replace this field, write it back. Still
# inside `self.lock`, so nothing lands in between.
registers = conversion.merge_into_registers(
entity.desc,
value,
await self._read_current_registers(entity),
)
else:
registers = conversion.convert_to_registers(entity.desc, value)
_LOGGER.debug(
"Raw value after conversion to registers: %s (type: %s)",
registers,
Expand Down
Loading
Loading