From 574b461c13cb3606e14aa585f9d4bbf27805ede2 Mon Sep 17 00:00:00 2001 From: Markus Becker Date: Mon, 28 Sep 2026 08:35:56 +0200 Subject: [PATCH 01/21] Add general sensor support for IEC 62386 part 306 with tests --- dali/device/__init__.py | 1 + dali/device/general_sensor.py | 209 ++++++++++++++++++++++++++++++ dali/tests/test_general_sensor.py | 53 ++++++++ 3 files changed, 263 insertions(+) create mode 100644 dali/device/general_sensor.py create mode 100644 dali/tests/test_general_sensor.py diff --git a/dali/device/__init__.py b/dali/device/__init__.py index cef9901a..0ef1e4cf 100644 --- a/dali/device/__init__.py +++ b/dali/device/__init__.py @@ -9,3 +9,4 @@ import dali.device.pushbutton # noqa: F401 import dali.device.occupancy # noqa: F401 import dali.device.light # noqa: F401 +import dali.device.general_sensor # noqa: F401 diff --git a/dali/device/general_sensor.py b/dali/device/general_sensor.py new file mode 100644 index 00000000..25a5854f --- /dev/null +++ b/dali/device/general_sensor.py @@ -0,0 +1,209 @@ +"""Commands and events from IEC 62386 part 306: General sensors.""" + +from enum import IntEnum + +from dali import command +from dali.device import general + +# IEC 62386-306 instance type. +instance_type = 6 + + +class MeasurementVariable(IntEnum): + """DTR0 values for QUERY MEASUREMENT VARIABLE, from Part 306 Table 17.""" + + ALARM_IS_ACTIVATED = 0x00 + ALARM_TYPE = 0x01 + INPUT_SIGNAL_SIGNED = 0x02 + ALARM_0_BYTE_0 = 0x03 + ALARM_0_BYTE_1 = 0x04 + ALARM_0_BYTE_2 = 0x05 + ALARM_1_BYTE_0 = 0x06 + ALARM_1_BYTE_1 = 0x07 + ALARM_1_BYTE_2 = 0x08 + ALARM_2_BYTE_0 = 0x09 + ALARM_2_BYTE_1 = 0x0A + ALARM_2_BYTE_2 = 0x0B + ALARM_3_BYTE_0 = 0x0C + ALARM_3_BYTE_1 = 0x0D + ALARM_3_BYTE_2 = 0x0E + ALARM_0_HYSTERESIS_BYTE_0 = 0x0F + ALARM_0_HYSTERESIS_BYTE_1 = 0x10 + ALARM_0_HYSTERESIS_BYTE_2 = 0x11 + ALARM_1_HYSTERESIS_BYTE_0 = 0x12 + ALARM_1_HYSTERESIS_BYTE_1 = 0x13 + ALARM_1_HYSTERESIS_BYTE_2 = 0x14 + ALARM_2_HYSTERESIS_BYTE_0 = 0x15 + ALARM_2_HYSTERESIS_BYTE_1 = 0x16 + ALARM_2_HYSTERESIS_BYTE_2 = 0x17 + ALARM_3_HYSTERESIS_BYTE_0 = 0x18 + ALARM_3_HYSTERESIS_BYTE_1 = 0x19 + ALARM_3_HYSTERESIS_BYTE_2 = 0x1A + UNIT_OF_MEASUREMENT = 0x1B + UNIT_OF_MEASUREMENT_EXTENDED_0 = 0x1C + UNIT_OF_MEASUREMENT_EXTENDED_1 = 0x1D + QUANTITY_NAME = 0x1E + QUANTITY_NAME_EXTENDED_0 = 0x1F + QUANTITY_NAME_EXTENDED_1 = 0x20 + MAGNITUDE = 0x21 + MAGNITUDE_PHYSICAL_MAX = 0x22 + MAGNITUDE_PHYSICAL_MIN = 0x23 + MAX_INPUT_VALUE_BYTE_0 = 0x24 + MAX_INPUT_VALUE_BYTE_1 = 0x25 + MAX_INPUT_VALUE_BYTE_2 = 0x26 + MAX_INPUT_VALUE_BYTE_3 = 0x27 + MIN_INPUT_VALUE_BYTE_0 = 0x28 + MIN_INPUT_VALUE_BYTE_1 = 0x29 + MIN_INPUT_VALUE_BYTE_2 = 0x2A + MIN_INPUT_VALUE_BYTE_3 = 0x2B + + +class UnitOfMeasurement(IntEnum): + """Standard unit-of-measurement values from Part 306 Annex A, Table 18.""" + + MANUFACTURER_DEFINED = 0 + DIMENSIONLESS = 1 + SECOND = 2 + HERTZ = 3 + METRE = 4 + KILOGRAM = 5 + VOLT = 6 + AMPERE = 7 + KELVIN = 8 + CELSIUS = 9 + FAHRENHEIT = 10 + MOLE = 11 + RAD = 13 + STERADIAN = 14 + NEWTON = 15 + PASCAL = 16 + PSI = 17 + JOULE = 18 + WATT = 19 + COULOMB = 20 + FARAD = 21 + OHM = 22 + SIEMENS = 23 + WEBER = 24 + TESLA = 25 + HENRY = 26 + AMPERE_PER_METRE = 27 + VOLTS_PER_METRE = 28 + LUMEN = 31 + LUX = 32 + CANDELA = 33 + CANDELA_PER_SQUARE_METRE = 34 + BECQUEREL = 36 + GRAY = 37 + SIEVERT = 38 + KATAL = 39 + SQUARE_METRE = 41 + CUBIC_METRE = 42 + METRE_PER_SECOND = 43 + METRE_PER_SECOND_SQUARED = 44 + CUBIC_METRE_PER_SECOND = 45 + KILOGRAM_PER_CUBIC_METRE = 46 + KILOGRAM_PER_SQUARE_METRE = 47 + CUBIC_METRE_PER_KILOGRAM = 48 + FEET = 49 + NEWTON_METRE = 51 + POSITION_COORDINATE = 53 + DECIBEL = 55 + PERCENT = 56 + PART_PER_THOUSAND = 57 + PART_PER_TEN_THOUSAND = 58 + PARTS_PER_MILLION = 59 + + +class QuantityName(IntEnum): + """Standard quantity-name values from Part 306 Annex A, Table 19.""" + + MANUFACTURER_DEFINED = 0 + TIME = 1 + FREQUENCY = 2 + LENGTH = 4 + FORCE = 5 + WEIGHT = 6 + MASS = 7 + VELOCITY = 8 + AREA = 9 + VOLUME = 10 + TORQUE = 11 + VOLTAGE = 13 + CURRENT = 14 + POWER = 15 + POWER_APPARENT = 16 + POWER_REACTIVE = 17 + ENERGY = 18 + POWER_FACTOR = 19 + SOUND_PRESSURE_LEVEL = 20 + CCT = 22 + CRI = 23 + RED_LIGHT = 24 + GREEN_LIGHT = 25 + BLUE_LIGHT = 26 + TEMPERATURE = 27 + WET_BULB_TEMPERATURE = 28 + ABSOLUTE_HUMIDITY = 29 + RELATIVE_HUMIDITY = 30 + DEW_POINT = 31 + PRESSURE = 32 + FLOW_RATE = 33 + CO2 = 35 + CO = 36 + VOC = 37 + NO2 = 38 + AMMONIA = 39 + PARTICULATE_MATTER_PM10 = 41 + PARTICULATE_MATTER_PM2_5 = 42 + AIR_QUALITY_INDEX = 43 + RSSI_IBEACON = 44 + RSSI_EDDYSTONE = 45 + RSSI_ALTBEACON = 46 + GLOBAL_POSITION = 47 + RELATIVE_POSITION = 48 + ALTITUDE = 49 + + +class GeneralSensorEvent(general._Event): + """A Part 306 measured-value event.""" + + _instance_type = instance_type + _event_info = 0x20000 + + @classmethod + def from_event_data(cls, event_data: int): + """Return this event class for a measurement event.""" + return cls if event_data & 0x200 else None + + @property + def measured_value(self) -> int: + """Return the 9-bit measured-value event payload.""" + return self.event_data & 0x1FF + + @property + def event_data(self) -> int: + """Return the encoded event information.""" + return self._event_info + + def _set_event_data(self, set_data: int, set_frame) -> None: + """Encode the measured value into a Part 306 event frame.""" + if not isinstance(set_data, int) or not 0 <= set_data <= 0x3FF: + raise ValueError("GeneralSensorEvent requires 9-bit event data") + self._event_info = 0x200 | (set_data & 0x1FF) + set_frame[9:0] = self._event_info + + +class InstanceEventFilter(general.InstanceEventFilter): + """Part 306 event filters.""" + + measured_value = 0x01 + + +class QueryMeasurementVariable(general._StandardInstanceCommand): + """Query a Part 306 measurement variable selected by DTR0.""" + + inputdev = True + uses_dtr0 = True + response = command.NumericResponse + _opcode = 0x5F diff --git a/dali/tests/test_general_sensor.py b/dali/tests/test_general_sensor.py new file mode 100644 index 00000000..fd0fc065 --- /dev/null +++ b/dali/tests/test_general_sensor.py @@ -0,0 +1,53 @@ +"""Tests for IEC 62386 part 306 general sensor support.""" + +from dali.address import DeviceShort, InstanceNumber +from dali.device import general, general_sensor +from dali.device.general_sensor import ( + GeneralSensorEvent, + MeasurementVariable, + QuantityName, + UnitOfMeasurement, +) + + +def test_measurement_event_round_trip() -> None: + """Test encoding and decoding a measurement event.""" + event = GeneralSensorEvent( + short_address=DeviceShort(3), + data=0x155, + ) + + assert event.measured_value == 0x155 + decoded = general._Event.from_frame(event.frame) + assert isinstance(decoded, GeneralSensorEvent) + assert decoded.measured_value == 0x155 + + +def test_non_measurement_event_is_not_general_sensor() -> None: + """Test Part 306 alarm/reserved payloads are not measurement events.""" + assert GeneralSensorEvent.from_event_data(0x001) is None + + +def test_measurement_variable_command() -> None: + """Test the Part 306 measurement-variable query command.""" + command = general_sensor.QueryMeasurementVariable(DeviceShort(1), InstanceNumber(2)) + + assert command._opcode == 0x5F + assert command.uses_dtr0 + assert command.response is not None + + +def test_part_306_constants() -> None: + """Test normative quantity and unit constants.""" + assert general_sensor.instance_type == 6 + assert MeasurementVariable.QUANTITY_NAME == 0x1E + assert QuantityName.TEMPERATURE == 27 + assert QuantityName.RELATIVE_HUMIDITY == 30 + assert QuantityName.PRESSURE == 32 + assert QuantityName.CO2 == 35 + assert QuantityName.VOC == 37 + assert QuantityName.AIR_QUALITY_INDEX == 43 + assert UnitOfMeasurement.CELSIUS == 9 + assert UnitOfMeasurement.PASCAL == 16 + assert UnitOfMeasurement.PERCENT == 56 + assert UnitOfMeasurement.PARTS_PER_MILLION == 59 From 166caf95e24e34ef04af66e120a740e7542f4ca2 Mon Sep 17 00:00:00 2001 From: Markus Becker Date: Mon, 28 Sep 2026 14:54:46 +0200 Subject: [PATCH 02/21] Use serialx instead of pyserial-asyncio for serial drivers pyserial-asyncio is deprecated. Switch the RS232 serial drivers to serialx, which provides a compatible create_serial_connection and SerialTransport API, and update the driver-serial and test extras. Bump version to 0.12. Co-Authored-By: Claude Opus 4.8 --- dali/driver/serial.py | 10 +++++----- setup.cfg | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/dali/driver/serial.py b/dali/driver/serial.py index 24ba70b2..cc348f56 100644 --- a/dali/driver/serial.py +++ b/dali/driver/serial.py @@ -29,7 +29,7 @@ from typing import Any, Callable, Generator, NamedTuple, Optional from urllib.parse import ParseResult, urlparse, urlunparse -import serial_asyncio +import serialx import dali.gear from dali import command, frame, gear, sequences @@ -1016,7 +1016,7 @@ def __init__( self.serial_path = self.uri.path _LOG.info(f"Initialising luba232 driver for '{self.serial_path}'") - self._transport: Optional[serial_asyncio.SerialTransport] = None + self._transport: Optional[serialx.SerialTransport] = None self._protocol: Optional[DriverLubaRs232.LubaProtocol] = None async def connect(self, *, scan_dev_inst: bool = False) -> None: @@ -1030,7 +1030,7 @@ async def connect(self, *, scan_dev_inst: bool = False) -> None: ( self._transport, self._protocol, - ) = await serial_asyncio.create_serial_connection( + ) = await serialx.create_serial_connection( loop=asyncio.get_event_loop(), protocol_factory=DriverLubaRs232.LubaProtocol, url=self.serial_path, @@ -1603,7 +1603,7 @@ def __init__( self.serial_path = self.uri.path _LOG.info(f"Initialising SCI RS232 driver for '{self.serial_path}'") - self._transport: Optional[serial_asyncio.SerialTransport] = None + self._transport: Optional[serialx.SerialTransport] = None self._protocol: Optional[DriverSCIRS232.SCIRS232Protocol] = None async def connect(self, *, scan_dev_inst: bool = False) -> None: @@ -1621,7 +1621,7 @@ async def connect(self, *, scan_dev_inst: bool = False) -> None: ( self._transport, self._protocol, - ) = await serial_asyncio.create_serial_connection( + ) = await serialx.create_serial_connection( loop=asyncio.get_event_loop(), protocol_factory=DriverSCIRS232.SCIRS232Protocol, url=self.serial_path, diff --git a/setup.cfg b/setup.cfg index e2d4b4eb..853dae75 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = python-dali -version = 0.11 +version = 0.12 description = Interface to DALI lighting systems long_description = file: README.rst long_description_content_type = text/x-rst @@ -33,11 +33,11 @@ packages = driver-unipi = pyusb pymodbus -driver-serial = pyserial-asyncio +driver-serial = serialx test = pytest pytest-asyncio - pyserial-asyncio + serialx [flake8] exclude = */driver/ From 8ca43127878487f237f26f2b2149ad89f6ad1805 Mon Sep 17 00:00:00 2001 From: Markus Becker Date: Mon, 28 Sep 2026 15:59:33 +0200 Subject: [PATCH 03/21] Add GitHub Actions workflow to publish to PyPI on tag Build sdist and wheel and publish to PyPI via trusted publishing when a v* tag is pushed. Verify the tag matches the package version before building. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/publish.yml | 55 +++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..8ce72b4d --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,55 @@ +name: Publish to PyPI + +on: + push: + tags: + - "v*" + +permissions: + contents: read + +jobs: + build: + name: Build distribution + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Verify tag matches package version + run: | + version="$(python setup.py --version)" + tag="${GITHUB_REF_NAME#v}" + if [ "$version" != "$tag" ]; then + echo "Tag $tag does not match package version $version" >&2 + exit 1 + fi + - name: Build sdist and wheel + run: | + python -m pip install --upgrade pip build + python -m build + - name: Upload distribution artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/python-dali/ + permissions: + id-token: write + steps: + - name: Download distribution artifacts + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 From 5a101242b6ee225446dd443bac517e6bdc086bb0 Mon Sep 17 00:00:00 2001 From: Markus Becker Date: Tue, 29 Sep 2026 15:23:33 +0200 Subject: [PATCH 04/21] Fix connection_lost crash after serialx migration serialx's SerialTransport has no `loop` attribute, so the LUBA and SCI connection_lost handlers raised AttributeError on disconnect. Calling loop.stop() was also a leftover from the standalone-script era and would stop the shared asyncio event loop of any embedding application. Signal the disconnect through the existing connected event instead, which mirrors connection_made setting it. Co-Authored-By: Claude Opus 4.8 --- dali/driver/serial.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dali/driver/serial.py b/dali/driver/serial.py index cc348f56..082b8ba2 100644 --- a/dali/driver/serial.py +++ b/dali/driver/serial.py @@ -997,7 +997,7 @@ def data_received(self, data): def connection_lost(self, exc): _LOG.info("Serial port closed") - self.transport.loop.stop() + self._connected.clear() @property def connected(self) -> asyncio.Event: @@ -1583,7 +1583,7 @@ def data_received(self, data): def connection_lost(self, exc): _LOG.info("Serial port closed") - self.transport.loop.stop() + self._connected.clear() @property def connected(self) -> asyncio.Event: From 93ac04cace6b54cb8035c31a39b953e09af78370 Mon Sep 17 00:00:00 2001 From: Markus Becker Date: Tue, 29 Sep 2026 15:24:21 +0200 Subject: [PATCH 05/21] Add switchable bus power supply support to LUBA driver Lunatone DALI interfaces with an integrated power supply (e.g. the DALI-2 USB PS) leave the bus unpowered by default, so every transmission fails with "error in transmission: 1". Add an opt-in `bus_power` option to DriverLubaRs232 that sets HardwareSettings bit 7 when writing the device settings, switching on the interface's bus power supply. To let callers decide safely (a separate DALI power supply must not clash with the interface's own), implement the two queries needed to detect the situation: - QUERY DEVICE DESCRIPTOR (0x28/0x29): exposes whether the interface has a switchable bus power supply (HardwareFeature bit 7). - READ STATUS (0x2C/0x2D): exposes whether the bus currently has a voltage error, i.e. is unpowered (Status bit 7). Co-Authored-By: Claude Opus 4.8 --- dali/driver/serial.py | 185 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 184 insertions(+), 1 deletion(-) diff --git a/dali/driver/serial.py b/dali/driver/serial.py index 082b8ba2..3c4c1e85 100644 --- a/dali/driver/serial.py +++ b/dali/driver/serial.py @@ -315,6 +315,8 @@ class LubaCmd(Enum): READ_STATUS_RSP = 0x2D QUERY_DEVICE_INFO_CMD = 0x20 QUERY_DEVICE_INFO_RSP = 0x21 + QUERY_DEVICE_DESCRIPTOR_CMD = 0x28 + QUERY_DEVICE_DESCRIPTOR_RSP = 0x29 EVENT_MESSAGE = 0x31 ADD_DALI_FRAME_TO_TX_CMD = 0x32 ADD_DALI_FRAME_TO_TX_RSP = 0x33 @@ -342,6 +344,26 @@ class LubaDeviceSettings(NamedTuple): mode: int event_filter: int + class LubaDeviceDescriptor(NamedTuple): + """ + Named tuple describing the functionality of the LUBA interface, as + returned by the "QUERY DEVICE DESCRIPTOR" command + """ + + # HardwareFeature bit 7: device has an integrated, switchable bus + # power supply + has_bus_power_supply: bool + + class LubaLineStatus(NamedTuple): + """ + Named tuple for the status of a DALI line, as returned by the + "READ STATUS" command + """ + + # Status bit 7: a bus voltage error is present (i.e. the bus is not + # powered) + bus_voltage_error: bool + class LubaProtocol(asyncio.Protocol): """ This class is internally used by DriverLubaRs232 to implement a state @@ -392,6 +414,8 @@ def __init__(self) -> None: self._connected = asyncio.Event() self._dev_info: Optional[DriverLubaRs232.LubaDeviceInfo] = None self._dev_inst_map: Optional[DeviceInstanceTypeMapper] = None + # Whether to switch on the interface's integrated bus power supply + self.bus_power = False self.reset() @@ -580,6 +604,81 @@ async def send_device_info_query(self) -> None: _LOG.error(f"Expected a LubaDeviceInfo, but got: {dev_info}") return + async def send_device_descriptor_query( + self, + ) -> Optional[DriverLubaRs232.LubaDeviceDescriptor]: + """ + Query the LUBA device descriptor, describing which features (such + as a switchable bus power supply) the interface supports + """ + # Use a mutex to ensure only one message is sent at a time + async with self._tx_lock: + _LOG.debug("Querying LUBA device descriptor") + tx_ints = [ + 0x59, # ASCII 'Y' + DriverLubaRs232.LubaCmd.QUERY_DEVICE_DESCRIPTOR_CMD.value, + 0, # Length, this command carries no data + None, # Checksum + ] + self._insert_checksum(tx_ints) + + _LOG.trace( + f"LUBA frame to send: {[f'0x{data:02x}' for data in tx_ints]}" + ) + self.transport.write(bytearray(tx_ints)) + + descriptor = await asyncio.wait_for( + self._queue_rx_luba_cmd.get(), + timeout=DriverLubaRs232.timeout_tx_confirm, + ) + # Release transmit mutex + + if not isinstance( + descriptor, DriverLubaRs232.LubaDeviceDescriptor + ): + _LOG.error( + f"Expected a LubaDeviceDescriptor, but got: {descriptor}" + ) + return None + return descriptor + + async def send_read_status( + self, + ) -> Optional[DriverLubaRs232.LubaLineStatus]: + """ + Read the status of the (single) DALI line, which reports whether + the bus is powered + """ + # Use a mutex to ensure only one message is sent at a time + async with self._tx_lock: + _LOG.debug("Reading LUBA line status") + tx_ints = [ + 0x59, # ASCII 'Y' + DriverLubaRs232.LubaCmd.READ_STATUS_CMD.value, + 1, # Length + 0, # Line 0 (devices with a single DALI line) + None, # Checksum + ] + self._insert_checksum(tx_ints) + + _LOG.trace( + f"LUBA frame to send: {[f'0x{data:02x}' for data in tx_ints]}" + ) + self.transport.write(bytearray(tx_ints)) + + line_status = await asyncio.wait_for( + self._queue_rx_luba_cmd.get(), + timeout=DriverLubaRs232.timeout_tx_confirm, + ) + # Release transmit mutex + + if not isinstance(line_status, DriverLubaRs232.LubaLineStatus): + _LOG.error( + f"Expected a LubaLineStatus, but got: {line_status}" + ) + return None + return line_status + async def send_device_settings(self) -> None: """ The implementation of the LUBA protocol assumes a certain @@ -603,7 +702,7 @@ async def send_device_settings(self) -> None: # 2: 1 = deactivates including the line number in events # 1: 1 = Deactivates events for macros # 0: reserved - hardware_settings = 0b00000000 + hardware_settings = 0b10000000 if self.bus_power else 0b00000000 # 7: 1 = turn on bus power supply # 6..0: Reserved tx_ints = [ @@ -749,6 +848,15 @@ def _process_byte(self, rx_int: int) -> None: rx_cmd == DriverLubaRs232.LubaCmd.QUERY_DEVICE_INFO_RSP ): self._process_luba_response_device_info(received_data) + elif ( + rx_cmd + == DriverLubaRs232.LubaCmd.QUERY_DEVICE_DESCRIPTOR_RSP + ): + self._process_luba_response_device_descriptor( + received_data + ) + elif rx_cmd == DriverLubaRs232.LubaCmd.READ_STATUS_RSP: + self._process_luba_response_status(received_data) elif ( rx_cmd == DriverLubaRs232.LubaCmd.READ_WRITE_SETTINGS_RSP @@ -967,6 +1075,63 @@ def _process_luba_response_device_info(self, received_data: tuple): self._dev_info = info self._queue_rx_luba_cmd.put_nowait(info) + def _process_luba_response_device_descriptor( + self, received_data: tuple + ): + """ + Handle a received "QUERY DEVICE DESCRIPTOR" response message + """ + if ( + DriverLubaRs232.LubaCmd(self._buffer[1]) + != DriverLubaRs232.LubaCmd.QUERY_DEVICE_DESCRIPTOR_RSP + ): + raise ValueError( + f"Wrong event type 0x{self._buffer[1]:02x}, expected 0x29" + ) + + payload_length = received_data[2] + # The DeviceDescriptor is 19 bytes, of which HardwareFeature is the + # last one. Only its bit 7 (switchable bus power supply) is used. + if payload_length < 19: + raise ValueError( + f"Unexpected payload length {payload_length} for QUERY " + "DEVICE DESCRIPTOR response" + ) + hardware_feature = received_data[3 + 18] + descriptor = DriverLubaRs232.LubaDeviceDescriptor( + has_bus_power_supply=bool(hardware_feature & 0b10000000), + ) + _LOG.info(f"Received device descriptor: {descriptor}") + self._queue_rx_luba_cmd.put_nowait(descriptor) + + def _process_luba_response_status(self, received_data: tuple): + """ + Handle a received "READ STATUS" response message + """ + if ( + DriverLubaRs232.LubaCmd(self._buffer[1]) + != DriverLubaRs232.LubaCmd.READ_STATUS_RSP + ): + raise ValueError( + f"Wrong event type 0x{self._buffer[1]:02x}, expected 0x2D" + ) + + payload_length = received_data[2] + # Payload layout: Line, Tick (2), ID, NrOfEntries, Status. The + # Status byte (payload index 5) is only present for an available + # DALI line; a length of 1 means the requested line does not exist. + if payload_length < 6: + raise ValueError( + f"READ STATUS response without status byte (payload " + f"length {payload_length})" + ) + status = received_data[3 + 5] + line_status = DriverLubaRs232.LubaLineStatus( + bus_voltage_error=bool(status & 0b10000000), + ) + _LOG.info(f"Received line status: {line_status}") + self._queue_rx_luba_cmd.put_nowait(line_status) + def _process_luba_response_settings(self, received_data: tuple): """ Handle a received "READ / WRITE SETTINGS" response message @@ -1011,11 +1176,14 @@ def __init__( self, uri: str | ParseResult, dev_inst_map: Optional[DeviceInstanceTypeMapper] = None, + bus_power: bool = False, ): super().__init__(uri=uri, dev_inst_map=dev_inst_map) self.serial_path = self.uri.path _LOG.info(f"Initialising luba232 driver for '{self.serial_path}'") + # Whether to switch on the interface's integrated bus power supply + self.bus_power = bus_power self._transport: Optional[serialx.SerialTransport] = None self._protocol: Optional[DriverLubaRs232.LubaProtocol] = None @@ -1046,6 +1214,7 @@ async def connect(self, *, scan_dev_inst: bool = False) -> None: raise await self._protocol.send_device_info_query() + self._protocol.bus_power = self.bus_power await self._protocol.send_device_settings() self._protocol.dev_inst_map = self.dev_inst_map @@ -1061,6 +1230,20 @@ async def connect(self, *, scan_dev_inst: bool = False) -> None: "device instances" ) + async def query_device_descriptor( + self, + ) -> Optional[DriverLubaRs232.LubaDeviceDescriptor]: + """Return the interface's device descriptor (feature flags).""" + if not self.is_connected: + raise IOError("DALI driver cannot query, not connected") + return await self._protocol.send_device_descriptor_query() + + async def read_status(self) -> Optional[DriverLubaRs232.LubaLineStatus]: + """Return the status of the DALI line (e.g. bus power state).""" + if not self.is_connected: + raise IOError("DALI driver cannot query, not connected") + return await self._protocol.send_read_status() + async def send( self, msg: command.Command, in_transaction: bool = False ) -> Optional[command.Response]: From 0928a3ec7f7a1173aa7cc2126a70d08221b12246 Mon Sep 17 00:00:00 2001 From: Markus Becker Date: Tue, 29 Sep 2026 16:35:05 +0200 Subject: [PATCH 06/21] Timeout bus power change. --- dali/driver/serial.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/dali/driver/serial.py b/dali/driver/serial.py index 3c4c1e85..b08ee853 100644 --- a/dali/driver/serial.py +++ b/dali/driver/serial.py @@ -301,6 +301,7 @@ class DriverLubaRs232(DriverSerialBase): timeout_rx = 0.025 timeout_tx_confirm = 1.0 # TX might take some time if the bus is busy timeout_connect = 1.0 + timeout_bus_power = 5.0 # Time for the integrated PS to bring the bus up class LubaCmd(Enum): """ @@ -1220,6 +1221,12 @@ async def connect(self, *, scan_dev_inst: bool = False) -> None: self._connected.set() + # After switching on the integrated power supply the bus voltage takes + # a moment to rise; sending DALI frames before then fails, so wait for + # the bus to be powered before scanning. + if self.bus_power: + await self._wait_for_bus_power() + # Scan the bus for control devices, and create a mapping of addresses # to instance types if scan_dev_inst: @@ -1230,6 +1237,17 @@ async def connect(self, *, scan_dev_inst: bool = False) -> None: "device instances" ) + async def _wait_for_bus_power(self) -> None: + """Wait for the integrated power supply to bring up the bus voltage.""" + loop = asyncio.get_event_loop() + deadline = loop.time() + DriverLubaRs232.timeout_bus_power + while loop.time() < deadline: + status = await self._protocol.send_read_status() + if status is not None and not status.bus_voltage_error: + return + await asyncio.sleep(0.1) + _LOG.warning("DALI bus voltage not detected after enabling bus power") + async def query_device_descriptor( self, ) -> Optional[DriverLubaRs232.LubaDeviceDescriptor]: From bf224047d61560e4c1c90a1ffe4e7077445537de Mon Sep 17 00:00:00 2001 From: Markus Becker Date: Wed, 30 Sep 2026 09:40:59 +0200 Subject: [PATCH 07/21] Fail fast when the LUBA interface rejects a DALI frame When the LUBA device reports an "ADD DALI FRAME TO TX BUFFER" error, no frame reaches the bus, so the "frame sent" event that normally confirms a transmission never arrives. send_dali_command() therefore blocked until the confirmation timeout before failing with a generic error. Surface the rejection immediately: enqueue a transmission confirmation carrying the interface's reason code, and raise a new TransmissionError from send_dali_command() as soon as it is dequeued. The reason codes are enumerated in LubaTxError (LUBA documentation section 2.1.1), letting callers act on specific causes such as an unpowered bus (bus voltage error). Also log frames that cannot be decoded from their transmit echo at debug, including the raw bytes, to aid diagnosis. Co-Authored-By: Claude Opus 4.8 --- dali/driver/serial.py | 34 +++++++++++++++++++++++++++++++++- dali/exceptions.py | 13 +++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/dali/driver/serial.py b/dali/driver/serial.py index b08ee853..080f5525 100644 --- a/dali/driver/serial.py +++ b/dali/driver/serial.py @@ -35,6 +35,7 @@ from dali import command, frame, gear, sequences from dali.driver import trace_logging # noqa: F401 from dali.device.helpers import DeviceInstanceTypeMapper +from dali.exceptions import TransmissionError _LOG = logging.getLogger("dali.driver") @@ -326,6 +327,21 @@ class LubaCmd(Enum): ADD_24DALI_FRAME_TO_TX_CMD = 0x36 ADD_24DALI_FRAME_TO_TX_RSP = 0x37 + class LubaTxError(Enum): + """Reason codes reported when the interface rejects a DALI frame. + + Refer §2.1.1 "ADD DALI FRAME TO TX BUFFER" of Lunatone's LUBA + documentation. + """ + + BUS_VOLTAGE_ERROR = 1 + DALI_INITIALIZE_MODE = 2 + DALI_QUIESCENT_MODE = 3 + SEND_BUFFER_FULL = 4 + LINE_NOT_AVAILABLE = 5 + SYNTAX_ERROR = 6 + MACRO_RUNNING = 7 + class LubaDeviceInfo(NamedTuple): """ Named tuple for storing a set of information about the LUBA device @@ -395,6 +411,7 @@ class LubaMsgTxConf(NamedTuple): tx_id: int message: Optional[command.Command] = None + error_code: Optional[int] = None def __init__(self) -> None: super().__init__() @@ -550,6 +567,8 @@ async def send_dali_command(self, tx: command.Command) -> None: self._queue_tx_conf.get(), timeout=DriverLubaRs232.timeout_tx_confirm, ) + if confirm.error_code is not None: + raise TransmissionError(confirm.error_code) if hasattr(confirm.message, "frame"): if confirm.message.frame == tx.frame: _LOG.trace( @@ -916,7 +935,13 @@ def _process_luba_event(self, received_data: tuple): devicetype=self._prev_tx_enable_dt, dev_inst_map=self._dev_inst_map, ) - except: + except Exception: + _LOG.debug( + "Failed to decode transmitted DALI frame " + f"{[f'0x{b:02x}' for b in tx_dali]} " + f"(devicetype {self._prev_tx_enable_dt})", + exc_info=True, + ) dali_command = None # Store the last seen Device Type command, there will likely # be a subsequent command that we transmit which relies on @@ -1013,6 +1038,13 @@ def _process_luba_response_dali_frame_to_tx(self, received_data: tuple): _LOG.error( f"LUBA device reports error in transmission: {error_code}" ) + # No DALI frame reaches the bus, so the "frame sent" event that + # normally confirms a transmission will never arrive. Surface + # the error to the waiting 'send_dali_command' so it fails fast + # instead of blocking until the confirmation timeout. + self._queue_tx_conf.put_nowait( + self.LubaMsgTxConf(tx_id=-1, error_code=error_code) + ) elif payload_length == 2: tx_id = received_data[3] _LOG.trace( diff --git a/dali/exceptions.py b/dali/exceptions.py index 91dbbc9a..fd06e535 100644 --- a/dali/exceptions.py +++ b/dali/exceptions.py @@ -119,6 +119,19 @@ class CommunicationError(DriverError): """ +class TransmissionError(DriverError): + """The interface rejected transmission of a DALI frame. + + ``error_code`` is the interface-specific reason for the rejection. + """ + + def __init__(self, error_code): + self.error_code = error_code + super().__init__( + f"Frame transmission rejected, error code {error_code}" + ) + + class UnsupportedFrameTypeError(DriverError): """Device driver does not support this type of frame """ From 1bf308dcd994817bbcb094ca3de4ba2a66db313e Mon Sep 17 00:00:00 2001 From: Markus Becker Date: Wed, 30 Sep 2026 09:41:24 +0200 Subject: [PATCH 08/21] Log undecodable transmit echoes at debug instead of warning The LUBA device echoes each transmitted frame back so the driver can verify it against what it sent. Some valid frames (for example extended DT8 colour queries) cannot yet be round-tripped through Command.from_frame, but the frame was still sent successfully, as the interface itself confirmed. Emitting a warning for every such frame floods the log during normal polling for a purely cosmetic decode gap, so drop it to debug. Co-Authored-By: Claude Opus 4.8 --- dali/driver/serial.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dali/driver/serial.py b/dali/driver/serial.py index 080f5525..46b92df0 100644 --- a/dali/driver/serial.py +++ b/dali/driver/serial.py @@ -582,7 +582,7 @@ async def send_dali_command(self, tx: command.Command) -> None: f"for '{confirm.message}'" ) else: - _LOG.warning( + _LOG.debug( f"Unable to decode message id {confirm.tx_id}, but " "LUBA device reports it was sent successfully" ) From 21a5fb8a3237d241c86cc510849f9d7999f0c82d Mon Sep 17 00:00:00 2001 From: Markus Becker Date: Wed, 30 Sep 2026 09:43:18 +0200 Subject: [PATCH 09/21] Mark Lunatone article nr. 24138246 as tested The DALI-2 USB PS (article nr. 24138246) has been exercised successfully with this driver, so add it to the set of tested article numbers alongside 24166096. Collect them in TESTED_ARTICLE_NUMBERS and widen the "untested device" warning to list every tested number. Co-Authored-By: Claude Opus 4.8 --- dali/driver/serial.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/dali/driver/serial.py b/dali/driver/serial.py index 46b92df0..e6a94b67 100644 --- a/dali/driver/serial.py +++ b/dali/driver/serial.py @@ -304,6 +304,9 @@ class DriverLubaRs232(DriverSerialBase): timeout_connect = 1.0 timeout_bus_power = 5.0 # Time for the integrated PS to bring the bus up + # Lunatone article numbers this driver has been tested against. + TESTED_ARTICLE_NUMBERS = frozenset({24166096, 24138246}) + class LubaCmd(Enum): """ All supported LUBA command codes. Refer §2 of Lunatone's @@ -1100,10 +1103,13 @@ def _process_luba_response_device_info(self, received_data: tuple): ), ) _LOG.info(f"Received device info: {info}") - if info.article_num != 24166096: + if info.article_num not in DriverLubaRs232.TESTED_ARTICLE_NUMBERS: + tested = ", ".join( + str(num) for num in sorted(DriverLubaRs232.TESTED_ARTICLE_NUMBERS) + ) _LOG.warning( "DriverLubaRs232 has only been tested with Lunatone article " - f"nr. 24166096, not nr. {info.article_num}" + f"nr. {tested}, not nr. {info.article_num}" ) self._dev_info = info self._queue_rx_luba_cmd.put_nowait(info) From 40b213db7dab9f524f79a913610f99a1aff7bc08 Mon Sep 17 00:00:00 2001 From: Markus Becker Date: Wed, 30 Sep 2026 12:33:32 +0200 Subject: [PATCH 10/21] Add DT8 colour and DT6 dimming-curve query sequences Add sequence helpers that wrap the application-extended queries so callers get the required EnableDeviceType prefix automatically: - QueryDT8ColourTypeFeatures and QueryDT8ColourStatus (DT8 / 62386-209) report which colour types a gear supports and which one is active. - QueryDT6DimmingCurve (DT6 / 62386-207) reports the configured dimming curve so a linear curve can be detected. Sent bare, these commands time out because the gear ignores an application-extended command without a preceding EnableDeviceType. Co-Authored-By: Claude Opus 4.8 --- dali/gear/sequences.py | 63 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/dali/gear/sequences.py b/dali/gear/sequences.py index e087e7de..73a74145 100644 --- a/dali/gear/sequences.py +++ b/dali/gear/sequences.py @@ -9,12 +9,15 @@ from dali.address import GearAddress, GearShort from dali.gear.colour import ( Activate, + QueryColourStatus, + QueryColourTypeFeatures, QueryColourValue, QueryColourValueDTR, SetTemporaryColourTemperature, StoreColourTemperatureTcLimit, ) from dali.gear.general import DTR0, DTR1, DTR2, QueryActualLevel, QueryContentDTR0 +from dali.gear.led import QueryDimmingCurve def SetDT8ColourValueTc( @@ -42,6 +45,66 @@ def SetDT8ColourValueTc( yield Activate(address) +def QueryDT8ColourTypeFeatures( + address: GearShort, +) -> Generator[command.Command, Optional[command.Response], Optional[command.Response]]: + """ + A generator sequence to query the colour type features of a DT8 control + gear (62386-209, command 249). Running it as a sequence ensures the + required "ENABLE DEVICE TYPE 8" command is sent immediately beforehand, so + the gear answers the application extended command. + + :param address: GearShort address to query + :return: The QueryColourTypeFeaturesResponse, or None if no answer + """ + # Although the proper types are expected, ints are common enough for + # addresses and their meaning is unambiguous in this context + if isinstance(address, int): + address = GearShort(address) + + return (yield QueryColourTypeFeatures(address)) + + +def QueryDT8ColourStatus( + address: GearShort, +) -> Generator[command.Command, Optional[command.Response], Optional[command.Response]]: + """ + A generator sequence to query the colour status of a DT8 control gear + (62386-209, command 248), which reports the currently active colour type. + Running it as a sequence ensures the required "ENABLE DEVICE TYPE 8" + command is sent immediately beforehand. + + :param address: GearShort address to query + :return: The QueryColourStatusResponse, or None if no answer + """ + # Although the proper types are expected, ints are common enough for + # addresses and their meaning is unambiguous in this context + if isinstance(address, int): + address = GearShort(address) + + return (yield QueryColourStatus(address)) + + +def QueryDT6DimmingCurve( + address: GearShort, +) -> Generator[command.Command, Optional[command.Response], Optional[command.Response]]: + """ + A generator sequence to query the dimming curve of a DT6 (LED) control + gear (62386-207, command 238). Running it as a sequence ensures the + required "ENABLE DEVICE TYPE 6" command is sent immediately beforehand, so + the gear answers the application extended command. + + :param address: GearShort address to query + :return: The QueryDimmingCurveResponse, or None if no answer + """ + # Although the proper types are expected, ints are common enough for + # addresses and their meaning is unambiguous in this context + if isinstance(address, int): + address = GearShort(address) + + return (yield QueryDimmingCurve(address)) + + def QueryDT8ColourValue( address: GearShort, query: QueryColourValueDTR, From 91b362d02ba8300dc6fa1d9f66dc94ec2c7fc8e6 Mon Sep 17 00:00:00 2001 From: Markus Becker Date: Wed, 30 Sep 2026 12:52:50 +0200 Subject: [PATCH 11/21] Report timed-out queries with the command's response type A query that received no reply defaulted to a bare Response(None). That response passes check_bad_rsp while its value is None, so a timed-out QueryInstanceType leaked None into control-device autodiscovery and crashed on int(None), aborting driver setup. Default a query to the command's own response type instead, so a timeout is reported as a missing value and check_bad_rsp rejects it as expected. Co-Authored-By: Claude Opus 4.8 --- dali/driver/serial.py | 10 ++++++++-- dali/tests/test_serial_driver.py | 34 ++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 dali/tests/test_serial_driver.py diff --git a/dali/driver/serial.py b/dali/driver/serial.py index e6a94b67..d3c0aaf3 100644 --- a/dali/driver/serial.py +++ b/dali/driver/serial.py @@ -1318,7 +1318,10 @@ async def send( self._protocol.reset_dali_response() await self._protocol.send_dali_command(msg) if msg.is_query: - response = command.Response(None) + # Default to the command's own response type so that a query + # which times out is reported as a missing value; a bare + # Response(None) passes check_bad_rsp and leaks None to callers. + response = msg.response(None) while True: try: raw_rsp = await asyncio.wait_for( @@ -1909,7 +1912,10 @@ async def send( self._protocol.reset_dali_response() await self._protocol.send_dali_command(msg) if msg.is_query: - response = command.Response(None) + # Default to the command's own response type so that a query + # which times out is reported as a missing value; a bare + # Response(None) passes check_bad_rsp and leaks None to callers. + response = msg.response(None) while True: try: raw_rsp = await asyncio.wait_for( diff --git a/dali/tests/test_serial_driver.py b/dali/tests/test_serial_driver.py new file mode 100644 index 00000000..314fbf43 --- /dev/null +++ b/dali/tests/test_serial_driver.py @@ -0,0 +1,34 @@ +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from dali.address import DeviceShort, InstanceNumber +from dali.command import NumericResponse +from dali.device.general import QueryInstanceType +from dali.device.helpers import check_bad_rsp +from dali.driver.serial import DriverLubaRs232 + + +@pytest.mark.asyncio +async def test_timed_out_query_reports_missing_value(): + """A query that times out must return the command's own response type. + + A bare Response(None) passes check_bad_rsp while its value is None, which + leaked into control-device autodiscovery and crashed on int(None). + """ + driver = DriverLubaRs232("luba232:/dev/ttyACM0") + driver._connected.set() + protocol = MagicMock() + protocol.reset_dali_response = MagicMock() + protocol.send_dali_command = AsyncMock() + protocol.wait_dali_raw_response = AsyncMock(side_effect=asyncio.TimeoutError) + driver._protocol = protocol + + response = await driver.send( + QueryInstanceType(device=DeviceShort(0), instance=InstanceNumber(0)) + ) + + assert isinstance(response, NumericResponse) + assert response.value == "(missing)" + assert check_bad_rsp(response) From a07caa9f5a05944f8fe6df111920e1fece92c417 Mon Sep 17 00:00:00 2001 From: Markus Becker Date: Wed, 30 Sep 2026 12:55:46 +0200 Subject: [PATCH 12/21] Drain stale RX DALI frames quietly instead of logging CRITICAL Before each transaction the drivers clear any DALI frame left buffered from a previous exchange. A backward frame that arrives after its query's receive timeout is such a leftover and is expected on a busy bus, yet it was logged at CRITICAL on every occurrence, flooding the log. Two bugs made this worse: the queue was reported as holding N items but only a single item was ever discarded, and the SCI info-queue branch drained the raw-frame queue by mistake, so the info queue was never cleared. Drain each queue fully through a shared helper and log the discard at debug level. Co-Authored-By: Claude Opus 4.8 --- dali/driver/serial.py | 77 +++++++++++++++++--------------- dali/tests/test_serial_driver.py | 21 +++++++++ 2 files changed, 61 insertions(+), 37 deletions(-) diff --git a/dali/driver/serial.py b/dali/driver/serial.py index d3c0aaf3..981e4c7e 100644 --- a/dali/driver/serial.py +++ b/dali/driver/serial.py @@ -40,6 +40,16 @@ _LOG = logging.getLogger("dali.driver") +def _drain_queue(queue: asyncio.Queue) -> list: + """Remove and return every currently-queued item without blocking.""" + items = [] + while True: + try: + items.append(queue.get_nowait()) + except asyncio.QueueEmpty: + return items + + class DistributorQueue(asyncio.Queue): def __init__(self, parent: Optional[DistributorQueue] = None, *, maxsize=0): """ @@ -488,20 +498,19 @@ async def wait_dali_raw_response(self) -> int: return await self._queue_rx_raw_dali.get() def reset_dali_response(self) -> None: + """Drop any DALI responses left buffered from a previous exchange. + + A backward frame that arrives after its query's receive timeout has + no matching request, so it is cleared before the next transaction. + On a busy bus this is routine housekeeping, hence debug level. """ - Forces the queue of received DALI responses to be cleared, logging - any responses that are dropped if the queue is not empty - """ - qlen = self._queue_rx_raw_dali.qsize() - if qlen: - _LOG.critical( - f"LUBA RX DALI queue not empty! {qlen} items in queue!" + discarded = _drain_queue(self._queue_rx_raw_dali) + if discarded: + _LOG.debug( + "LUBA RX DALI queue: discarded %d stale response(s): %s", + len(discarded), + discarded, ) - try: - item = self._queue_rx_raw_dali.get_nowait() - _LOG.critical(f"LUBA RX DALI queue discarding: {item}") - except asyncio.QueueEmpty: - pass @staticmethod def _insert_checksum(in_ints: list[int]) -> None: @@ -1516,34 +1525,28 @@ async def wait_dali_raw_response(self) -> int: return await self._queue_rx_raw_dali.get() def reset_dali_response(self) -> None: - """ - Forces the queue of received DALI responses to be cleared, logging - any responses that are dropped if the queue is not empty - """ + """Drop any DALI frames left buffered from a previous exchange. - # remove backward frames - qlen = self._queue_rx_raw_dali.qsize() - if qlen: - _LOG.critical( - f"SCI RS232 RX DALI queue not empty! {qlen} items in queue!" + Backward frames and information frames that arrive after their + query's receive timeout have no matching request, so they are + cleared before the next transaction. On a busy bus this is routine + housekeeping, hence debug level. + """ + discarded = _drain_queue(self._queue_rx_raw_dali) + if discarded: + _LOG.debug( + "SCI RS232 RX DALI queue: discarded %d stale response(s): %s", + len(discarded), + discarded, ) - try: - item = self._queue_rx_raw_dali.get_nowait() - _LOG.critical(f"SCI RS232 RX DALI queue discarding: {item}") - except asyncio.QueueEmpty: - pass - - # remove information frames (includes errors and sent confirmations) - qlen = self._queue_rx_info.qsize() - if qlen: - _LOG.critical( - f"SCI RS232 RX info DALI queue not empty! {qlen} items in queue!" + + discarded_info = _drain_queue(self._queue_rx_info) + if discarded_info: + _LOG.debug( + "SCI RS232 RX info queue: discarded %d stale frame(s): %s", + len(discarded_info), + discarded_info, ) - try: - item = self._queue_rx_raw_dali.get_nowait() - _LOG.critical(f"SCI RS232 RX info DALI queue discarding: {item}") - except asyncio.QueueEmpty: - pass @staticmethod def _insert_checksum(in_ints: list[int]) -> None: diff --git a/dali/tests/test_serial_driver.py b/dali/tests/test_serial_driver.py index 314fbf43..4d765ec1 100644 --- a/dali/tests/test_serial_driver.py +++ b/dali/tests/test_serial_driver.py @@ -1,4 +1,5 @@ import asyncio +import logging from unittest.mock import AsyncMock, MagicMock import pytest @@ -32,3 +33,23 @@ async def test_timed_out_query_reports_missing_value(): assert isinstance(response, NumericResponse) assert response.value == "(missing)" assert check_bad_rsp(response) + + +@pytest.mark.asyncio +async def test_reset_dali_response_drains_all_without_critical(caplog): + """Stale buffered responses must all be cleared and not logged as CRITICAL. + + A late backward frame that missed its query is routine on a busy bus; the + old code discarded only one item and logged it at CRITICAL, spamming logs. + """ + protocol = DriverLubaRs232.LubaProtocol() + protocol._queue_rx_raw_dali.put_nowait(6) + protocol._queue_rx_raw_dali.put_nowait(7) + protocol._queue_rx_raw_dali.put_nowait(8) + + with caplog.at_level(logging.DEBUG, logger="dali.driver"): + protocol.reset_dali_response() + + assert protocol._queue_rx_raw_dali.empty() + assert not [r for r in caplog.records if r.levelno >= logging.CRITICAL] + assert any("discarded 3 stale" in r.getMessage() for r in caplog.records) From b88641024f267dfb2fb19ebdc27d51f86b253ded Mon Sep 17 00:00:00 2001 From: Markus Becker Date: Thu, 1 Oct 2026 13:15:41 +0200 Subject: [PATCH 13/21] Align Part 306 sensor enums with the IEC 62386-306 FDIS The MeasurementVariable, UnitOfMeasurement and QuantityName values were taken from an earlier draft whose numbering differs from the published FDIS. Several codes were off, so a sensor's reported quantity or unit decoded to the wrong type. Renumber all three enums to the FDIS tables (including the newly added quantities and units) and rename the MAX/MIN input-value variables to MAX/MIN_MEASURED_VALUE to match Table 17. --- dali/device/general_sensor.py | 152 +++++++++++++++++------------- dali/tests/test_general_sensor.py | 16 ++-- 2 files changed, 94 insertions(+), 74 deletions(-) diff --git a/dali/device/general_sensor.py b/dali/device/general_sensor.py index 25a5854f..1ffe1bdb 100644 --- a/dali/device/general_sensor.py +++ b/dali/device/general_sensor.py @@ -10,7 +10,11 @@ class MeasurementVariable(IntEnum): - """DTR0 values for QUERY MEASUREMENT VARIABLE, from Part 306 Table 17.""" + """DTR0 values for QUERY MEASUREMENT VARIABLE, from Part 306 Table 17. + + Note: each QUERY MEASUREMENT VARIABLE execution auto-increments DTR0 by 1, + so DTR0 must be set again before every single-byte read. + """ ALARM_IS_ACTIVATED = 0x00 ALARM_TYPE = 0x01 @@ -48,18 +52,18 @@ class MeasurementVariable(IntEnum): MAGNITUDE = 0x21 MAGNITUDE_PHYSICAL_MAX = 0x22 MAGNITUDE_PHYSICAL_MIN = 0x23 - MAX_INPUT_VALUE_BYTE_0 = 0x24 - MAX_INPUT_VALUE_BYTE_1 = 0x25 - MAX_INPUT_VALUE_BYTE_2 = 0x26 - MAX_INPUT_VALUE_BYTE_3 = 0x27 - MIN_INPUT_VALUE_BYTE_0 = 0x28 - MIN_INPUT_VALUE_BYTE_1 = 0x29 - MIN_INPUT_VALUE_BYTE_2 = 0x2A - MIN_INPUT_VALUE_BYTE_3 = 0x2B + MAX_MEASURED_VALUE_BYTE_0 = 0x24 + MAX_MEASURED_VALUE_BYTE_1 = 0x25 + MAX_MEASURED_VALUE_BYTE_2 = 0x26 + MAX_MEASURED_VALUE_BYTE_3 = 0x27 + MIN_MEASURED_VALUE_BYTE_0 = 0x28 + MIN_MEASURED_VALUE_BYTE_1 = 0x29 + MIN_MEASURED_VALUE_BYTE_2 = 0x2A + MIN_MEASURED_VALUE_BYTE_3 = 0x2B class UnitOfMeasurement(IntEnum): - """Standard unit-of-measurement values from Part 306 Annex A, Table 18.""" + """Standard unit-of-measurement values from Part 306 Annex A, Table A.1.""" MANUFACTURER_DEFINED = 0 DIMENSIONLESS = 1 @@ -73,7 +77,8 @@ class UnitOfMeasurement(IntEnum): CELSIUS = 9 FAHRENHEIT = 10 MOLE = 11 - RAD = 13 + DEGREE_ANGLE = 12 + RADIAN = 13 STERADIAN = 14 NEWTON = 15 PASCAL = 16 @@ -88,47 +93,50 @@ class UnitOfMeasurement(IntEnum): TESLA = 25 HENRY = 26 AMPERE_PER_METRE = 27 - VOLTS_PER_METRE = 28 + VOLT_PER_METRE = 28 + AMPERE_HOUR = 29 + WATT_HOUR = 30 LUMEN = 31 LUX = 32 CANDELA = 33 CANDELA_PER_SQUARE_METRE = 34 - BECQUEREL = 36 - GRAY = 37 - SIEVERT = 38 - KATAL = 39 - SQUARE_METRE = 41 - CUBIC_METRE = 42 - METRE_PER_SECOND = 43 - METRE_PER_SECOND_SQUARED = 44 - CUBIC_METRE_PER_SECOND = 45 - KILOGRAM_PER_CUBIC_METRE = 46 - KILOGRAM_PER_SQUARE_METRE = 47 - CUBIC_METRE_PER_KILOGRAM = 48 - FEET = 49 - NEWTON_METRE = 51 - POSITION_COORDINATE = 53 - DECIBEL = 55 - PERCENT = 56 - PART_PER_THOUSAND = 57 - PART_PER_TEN_THOUSAND = 58 - PARTS_PER_MILLION = 59 + BECQUEREL = 35 + GRAY = 36 + SIEVERT = 37 + KATAL = 38 + SQUARE_METRE = 39 + CUBIC_METRE = 40 + METRE_PER_SECOND = 41 + METRE_PER_SECOND_SQUARED = 42 + CUBIC_METRE_PER_SECOND = 43 + KILOGRAM_PER_CUBIC_METRE = 44 + KILOGRAM_PER_SQUARE_METRE = 45 + CUBIC_METRE_PER_KILOGRAM = 46 + FOOT = 47 + NEWTON_METRE = 48 + DECIBEL = 49 + PERCENT = 50 + PART_PER_THOUSAND = 51 + PART_PER_TEN_THOUSAND = 52 + PARTS_PER_MILLION = 53 class QuantityName(IntEnum): - """Standard quantity-name values from Part 306 Annex A, Table 19.""" + """Standard quantity-name values from Part 306 Annex A, Table A.2.""" MANUFACTURER_DEFINED = 0 TIME = 1 FREQUENCY = 2 - LENGTH = 4 - FORCE = 5 - WEIGHT = 6 - MASS = 7 - VELOCITY = 8 - AREA = 9 - VOLUME = 10 - TORQUE = 11 + LENGTH = 3 + FORCE = 4 + WEIGHT = 5 + MASS = 6 + VELOCITY = 7 + ACCELERATION = 8 + ANGLE = 9 + AREA = 10 + VOLUME = 11 + TORQUE = 12 VOLTAGE = 13 CURRENT = 14 POWER = 15 @@ -137,32 +145,44 @@ class QuantityName(IntEnum): ENERGY = 18 POWER_FACTOR = 19 SOUND_PRESSURE_LEVEL = 20 - CCT = 22 - CRI = 23 - RED_LIGHT = 24 - GREEN_LIGHT = 25 - BLUE_LIGHT = 26 - TEMPERATURE = 27 - WET_BULB_TEMPERATURE = 28 - ABSOLUTE_HUMIDITY = 29 - RELATIVE_HUMIDITY = 30 - DEW_POINT = 31 - PRESSURE = 32 - FLOW_RATE = 33 - CO2 = 35 - CO = 36 - VOC = 37 - NO2 = 38 + CCT = 21 + CRI = 22 + RED_LIGHT = 23 + GREEN_LIGHT = 24 + BLUE_LIGHT = 25 + TEMPERATURE = 26 + WET_BULB_TEMPERATURE = 27 + ABSOLUTE_HUMIDITY = 28 + RELATIVE_HUMIDITY = 29 + DEW_POINT = 30 + PRESSURE = 31 + FLOW_RATE = 32 + SO2 = 33 + CO2 = 34 + CO = 35 + VOC = 36 + NOX = 37 + N2O = 38 AMMONIA = 39 - PARTICULATE_MATTER_PM10 = 41 - PARTICULATE_MATTER_PM2_5 = 42 - AIR_QUALITY_INDEX = 43 - RSSI_IBEACON = 44 - RSSI_EDDYSTONE = 45 - RSSI_ALTBEACON = 46 - GLOBAL_POSITION = 47 - RELATIVE_POSITION = 48 - ALTITUDE = 49 + OZONE = 40 + CHLORINE = 41 + METHANE = 42 + ACIDITY = 43 + PARTICULATE_MATTER_PM10 = 44 + PARTICULATE_MATTER_PM2_5 = 45 + AIR_QUALITY_INDEX = 46 + RSSI_IBEACON = 47 + RSSI_EDDYSTONE = 48 + RSSI_ALTBEACON = 49 + GLOBAL_POSITION_LONGITUDE = 50 + GLOBAL_POSITION_LATITUDE = 51 + ALTITUDE = 52 + RELATIVE_POSITION_X = 53 + RELATIVE_POSITION_Y = 54 + RELATIVE_POSITION_Z = 55 + WIND_SPEED = 56 + FLUID_LEVEL = 57 + BATTERY_CHARGE = 58 class GeneralSensorEvent(general._Event): diff --git a/dali/tests/test_general_sensor.py b/dali/tests/test_general_sensor.py index fd0fc065..b33a28df 100644 --- a/dali/tests/test_general_sensor.py +++ b/dali/tests/test_general_sensor.py @@ -41,13 +41,13 @@ def test_part_306_constants() -> None: """Test normative quantity and unit constants.""" assert general_sensor.instance_type == 6 assert MeasurementVariable.QUANTITY_NAME == 0x1E - assert QuantityName.TEMPERATURE == 27 - assert QuantityName.RELATIVE_HUMIDITY == 30 - assert QuantityName.PRESSURE == 32 - assert QuantityName.CO2 == 35 - assert QuantityName.VOC == 37 - assert QuantityName.AIR_QUALITY_INDEX == 43 + assert QuantityName.TEMPERATURE == 26 + assert QuantityName.RELATIVE_HUMIDITY == 29 + assert QuantityName.PRESSURE == 31 + assert QuantityName.CO2 == 34 + assert QuantityName.VOC == 36 + assert QuantityName.AIR_QUALITY_INDEX == 46 assert UnitOfMeasurement.CELSIUS == 9 assert UnitOfMeasurement.PASCAL == 16 - assert UnitOfMeasurement.PERCENT == 56 - assert UnitOfMeasurement.PARTS_PER_MILLION == 59 + assert UnitOfMeasurement.PERCENT == 50 + assert UnitOfMeasurement.PARTS_PER_MILLION == 53 From a3658aee6c21018e36a8c74dd732c1c2934a074c Mon Sep 17 00:00:00 2001 From: Markus Becker Date: Thu, 1 Oct 2026 13:16:24 +0200 Subject: [PATCH 14/21] Make control-device autodiscover resilient to event collisions A spontaneous event from an input device can corrupt the backward frame of the first QueryDeviceStatus probe during a scan, which made a present device (and all its instances) look absent and get dropped. Let the bus settle after entering quiescent mode, and retry a status probe that comes back as a framing error while still treating a genuinely missing response as an empty address. Both the settle delay and retry count are tunable. --- dali/device/helpers.py | 30 ++++++++++++++--- dali/tests/test_device_sequences.py | 51 +++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/dali/device/helpers.py b/dali/device/helpers.py index 4c250a7b..0500ecc3 100644 --- a/dali/device/helpers.py +++ b/dali/device/helpers.py @@ -19,7 +19,7 @@ ) from dali.exceptions import MissingResponse, ResponseError from dali.frame import BackwardFrameError -from dali.sequences import progress as seq_progress +from dali.sequences import progress as seq_progress, sleep def check_bad_rsp(r: Response | None) -> bool: @@ -85,7 +85,10 @@ def mapping(self) -> dict[(int, int), int]: return self._mapping def autodiscover( - self, addresses: int | tuple[int, int] | Iterable[int] = (0, 63) + self, + addresses: int | tuple[int, int] | Iterable[int] = (0, 63), + quiescent_settle: float = 0.2, + status_attempts: int = 3, ) -> Generator[Command, Response, None]: """ A generator sequence to scan a DALI bus for control device instances, @@ -99,6 +102,11 @@ def autodiscover( which case all addresses between the provided values will be scanned; or finally can be an iterable of ints in which case each address, in the iterator will be scanned. + :param quiescent_settle: Seconds to wait after entering quiescent mode + before probing, so an event frame already in flight does not corrupt + the first address probe. + :param status_attempts: How many times to probe an address whose status + reply collides with a spontaneous event before treating it as empty. :return: A generator function, to use with e.g. `driver.run_sequence()` Needs to be used through an appropriate driver, with `run_sequence()`, @@ -111,6 +119,12 @@ def autodiscover( # Use quiescent mode to reduce bus contention from input devices yield StartQuiescentMode(DeviceBroadcast()) + # Quiescent mode is not instant and an input device may already have an + # event frame in flight. Let the bus settle so the first probe is not + # corrupted by a colliding backward frame, which would otherwise make a + # present device look absent. + if quiescent_settle: + yield sleep(quiescent_settle) if isinstance(addresses, int): addresses = (n for n in range(0, addresses)) @@ -120,8 +134,16 @@ def autodiscover( for addr_int in addresses: addr = DeviceShort(addr_int) - # Check that the device exists and responds - rsp = yield QueryDeviceStatus(device=addr) + # Check that the device exists and responds. A framing error means a + # device answered but its backward frame collided with a spontaneous + # event, so retry; a missing response means the address is empty, so + # stop probing it immediately. + for _attempt in range(status_attempts): + rsp = yield QueryDeviceStatus(device=addr) + if not check_bad_rsp(rsp): + break + if not isinstance(getattr(rsp, "raw_value", None), BackwardFrameError): + break if check_bad_rsp(rsp): continue if isinstance(rsp, QueryDeviceStatusResponse): diff --git a/dali/tests/test_device_sequences.py b/dali/tests/test_device_sequences.py index 910a8e14..8df3c1e2 100644 --- a/dali/tests/test_device_sequences.py +++ b/dali/tests/test_device_sequences.py @@ -110,6 +110,57 @@ def test_device_autodiscover_skip_no_instances(fakes_bus): assert len(dev_inst_map.mapping) == 12 +def test_device_autodiscover_retries_collided_status(fakes_bus): + """A status probe that collides with an event is retried, not skipped. + + A spontaneous event from a device can corrupt the backward frame of the + first status probe; without a retry the whole device (and all its + instances) would be silently dropped from the scan. + """ + dev_inst_map = DeviceInstanceTypeMapper() + real_send = fakes_bus.send + collided = {"done": False} + + def send_with_one_collision(cmd): + if ( + not collided["done"] + and isinstance(cmd, QueryDeviceStatus) + and getattr(cmd.destination, "address", None) == 0 + ): + collided["done"] = True + return QueryDeviceStatusResponse(BackwardFrameError(0xFF)) + return real_send(cmd) + + fakes_bus.send = send_with_one_collision + fakes_bus.run_sequence(dev_inst_map.autodiscover()) + + assert collided["done"] + assert any(addr == 0 for addr, _instance in dev_inst_map.mapping) + assert len(dev_inst_map.mapping) == 12 + + +def test_device_autodiscover_absent_address_probed_once(fakes_bus): + """An empty address returns a missing (not collided) status, so it is not + retried; only a framing-error reply triggers the extra probes.""" + probes: list[int] = [] + real_send = fakes_bus.send + + def counting_send(cmd): + if isinstance(cmd, QueryDeviceStatus): + probes.append(getattr(cmd.destination, "address", None)) + return real_send(cmd) + + fakes_bus.send = counting_send + fakes_bus.run_sequence( + DeviceInstanceTypeMapper().autodiscover(addresses=(0, 5)) + ) + + # Addresses 3-5 have no device, so each is probed exactly once. + assert probes.count(3) == 1 + assert probes.count(4) == 1 + assert probes.count(5) == 1 + + def test_device_query_event_scheme(fakes_bus): rsp = fakes_bus.send(QueryEventScheme(DeviceShort(1), InstanceNumber(1))) assert isinstance(rsp, QueryEventSchemeResponse) From 9beb2e8468e25a07a86dece8b6caf3d01b2e3e75 Mon Sep 17 00:00:00 2001 From: Markus Becker Date: Thu, 1 Oct 2026 13:16:51 +0200 Subject: [PATCH 15/21] Add a control-device commissioning sequence for part 103 devices Control gear had a Commissioning sequence but control devices (part 103) did not, so unaddressed sensors and pushbuttons could not be assigned short addresses programmatically. Add a Commissioning generator mirroring the gear one, using the part 103 24-bit randomise/compare/withdraw search and INITIALISE semantics (0xff all, 0x7f unaddressed). The bus is held quiescent for the search, a verify failure is raised only after quiescent mode is exited, and a same-random-address clash re-randomises and retries. The fake device bus gains the addressing commands so the sequence is tested end to end. --- dali/device/sequences.py | 177 +++++++++++++++++++++++++++- dali/tests/fakes.py | 66 +++++++++++ dali/tests/test_device_sequences.py | 82 +++++++++++++ 3 files changed, 323 insertions(+), 2 deletions(-) diff --git a/dali/device/sequences.py b/dali/device/sequences.py index acf0c0ca..3dc5797e 100644 --- a/dali/device/sequences.py +++ b/dali/device/sequences.py @@ -6,14 +6,18 @@ import types from typing import Generator, Optional, Type -from dali.address import DeviceShort, InstanceNumber +from dali.address import DeviceBroadcast, DeviceShort, InstanceNumber from dali.command import Command, Response from dali.device.general import ( DTR0, DTR1, DTR2, + Compare, EventScheme, + Initialise, InstanceEventFilter, + ProgramShortAddress, + QueryDeviceStatus, QueryResolution, QueryInputValue, QueryInputValueLatch, @@ -22,11 +26,22 @@ QueryEventFilterM, QueryEventScheme, QueryEventSchemeResponse, + Randomise, + SearchAddrH, + SearchAddrL, + SearchAddrM, SetEventFilter, SetEventScheme, + SetShortAddress, + StartQuiescentMode, + StopQuiescentMode, + Terminate, + VerifyShortAddress, + Withdraw, ) from dali.device.helpers import check_bad_rsp -from dali.exceptions import DALISequenceError +from dali.exceptions import DALISequenceError, ProgramShortAddressFailure +from dali.sequences import progress, sleep def SetEventSchemes( @@ -275,3 +290,161 @@ def query_input_value( value >>= 8 - resolution return value + + +# INITIALISE data byte values for control devices, from IEC 62386-103:2014 +# Table 21. Unlike control gear (part 102), devices use 0xff for "all" and +# 0x7f for "no short address" (short address == MASK). +_INITIALISE_ALL = 0xFF +_INITIALISE_UNADDRESSED = 0x7F + + +def _find_next( + low: int, high: int +) -> Generator[Command, Optional[Response], Optional[int | str]]: + """Binary search the 24-bit random address space for a responding device. + + Returns the random address of the device with the lowest address in the + ``low..high`` range, ``"clash"`` if two devices share the current address, + or ``None`` if no device answers in the range. + """ + yield SearchAddrH((high >> 16) & 0xFF) + yield SearchAddrM((high >> 8) & 0xFF) + yield SearchAddrL(high & 0xFF) + + r = yield Compare() + + if low == high: + if r.value is True: + return "clash" if r.raw_value.error else low + return None + + if r.value is True: + midpoint = (low + high) // 2 + res = yield from _find_next(low, midpoint) + if res is not None: + return res + return (yield from _find_next(midpoint + 1, high)) + + return None + + +def Commissioning( + available_addresses: Optional[list[int]] = None, + readdress: bool = False, + dry_run: bool = False, +) -> Generator[Command, Optional[Response], list[DeviceShort]]: + """Assign short addresses to 24-bit (part 103) control devices. + + This mirrors :func:`dali.sequences.Commissioning` for control gear, using + the part 103 addressing commands. Use with an appropriate DALI driver + instance, through its ``run_sequence()`` method. + + The bus is placed in quiescent mode for the duration so that spontaneous + event messages do not collide with the addressing backward frames. + + :param available_addresses: If passed, only these short addresses will be + assigned; otherwise all 64 short addresses are considered available. + :param readdress: If set, all existing short addresses are cleared first + and every device is readdressed; otherwise only devices that are + currently unaddressed are assigned a short address. + :param dry_run: If set, no short addresses are actually programmed. Useful + for testing the search without altering the bus. + :return: The list of short addresses that are in use once the sequence + completes. + """ + used_addresses: list[DeviceShort] = [] + if available_addresses is None: + available_addresses = list(range(64)) + else: + available_addresses = list(available_addresses) + + if readdress: + if dry_run: + yield progress( + message="dry_run is set: not deleting existing short addresses" + ) + else: + yield DTR0(255) + yield SetShortAddress(DeviceBroadcast()) + else: + # Devices that already have a short address answer QueryDeviceStatus at + # that address, so those addresses are considered already in use. + for a in list(available_addresses): + in_use = yield QueryDeviceStatus(DeviceShort(a)) + if not check_bad_rsp(in_use): + available_addresses.remove(a) + used_addresses.append(DeviceShort(a)) + yield progress(message=f"Available addresses: {available_addresses}") + + yield Terminate() + yield StartQuiescentMode(DeviceBroadcast()) + yield Initialise(_INITIALISE_ALL if readdress else _INITIALISE_UNADDRESSED) + + # A verify failure must still leave the bus addressable, so the error is + # recorded and raised only after quiescent mode has been exited below; + # yielding during exception unwinding is unsafe because the sequence runner + # closes the generator via GeneratorExit on failure. + failed_address: Optional[int] = None + finished = False + # Loop to cope with multiple devices picking the same random search + # address; when that clash is detected we re-randomise and begin again. + # Devices that already received addresses are unaffected. + while not finished: + yield Randomise() + # Randomise can take up to 200ms to settle. + yield sleep(0.2) + + low = 0 + high = 0xFFFFFF + + while low is not None: + yield progress(completed=low, size=high) + low = yield from _find_next(low, high) + if low == "clash": + yield progress( + message="Multiple devices picked the same random " + "address; restarting" + ) + break + if low is None: + finished = True + break + yield progress(message=f"Device found at address {low:#x}") + if available_addresses: + new_addr = available_addresses.pop(0) + if dry_run: + yield progress( + message="Not programming short address " + f"{new_addr} because dry_run is set" + ) + else: + yield progress( + message=f"Programming short address {new_addr}" + ) + yield ProgramShortAddress(new_addr) + r = yield VerifyShortAddress(new_addr) + if r.value is not True: + failed_address = new_addr + finished = True + break + used_addresses.append(DeviceShort(new_addr)) + else: + yield progress( + message="Device found but no short addresses left" + ) + yield Withdraw() + if low < high: + low = low + 1 + else: + low = None + finished = True + + yield Terminate() + yield StopQuiescentMode(DeviceBroadcast()) + + if failed_address is not None: + raise ProgramShortAddressFailure(failed_address) + + yield progress(message="Addressing complete") + return used_addresses diff --git a/dali/tests/fakes.py b/dali/tests/fakes.py index 3885cccf..8bb317bd 100644 --- a/dali/tests/fakes.py +++ b/dali/tests/fakes.py @@ -488,15 +488,22 @@ def __init__( shortaddr: Optional[address.DeviceShort] = None, groups: Optional[Iterable[address.DeviceGroup]] = None, memory_banks: Optional[Iterable[Type[FakeMemoryBank]]] = (FakeDeviceBank0,), + random_preload: Optional[Iterable[int]] = None, ): # Store parameters self.shortaddr = shortaddr self.groups = set(groups) if groups else set() + self.random_preload = list(random_preload) if random_preload else [] # Configure internal variables self.dtr0: int = 0 self.dtr1: int = 0 self.dtr2: int = 0 self.enable_write_memory: bool = False + self.initialising: bool = False + self.withdrawn: bool = False + self.quiescent: bool = False + self.randomaddr = frame.Frame(24) + self.searchaddr = frame.Frame(24) self.memory_banks = {} for fake_bank in memory_banks: bank_number = fake_bank.bank.address @@ -504,6 +511,24 @@ def __init__( raise ValueError(f"Duplicate memory bank {bank_number}") self.memory_banks[bank_number] = fake_bank() + def _next_random_address(self) -> int: + if self.random_preload: + return self.random_preload.pop(0) + return random.randrange(0, 0x1000000) + + def _initialise_reacts(self, param: int) -> bool: + """Whether an INITIALISE with this data byte selects this device. + + Per IEC 62386-103:2014 Table 21: 0xff addresses all devices, 0x7f + addresses devices without a short address, and 0x00..0x3f addresses + the device with that short address. + """ + if param == 0xFF: + return True + if param == 0x7F: + return self.shortaddr is None + return self.shortaddr is not None and self.shortaddr.address == param + def valid_address(self, cmd: Command) -> bool: """Should we respond to this command?""" if len(cmd.frame) != 24: @@ -591,6 +616,47 @@ def send(self, cmd: Command) -> Optional[int]: return self._device_status elif isinstance(cmd, device.general.QueryNumberOfInstances): return len(self._instances) + elif isinstance(cmd, device.general.Terminate): + self.initialising = False + self.withdrawn = False + elif isinstance(cmd, device.general.Initialise): + if self._initialise_reacts(cmd.param): + self.initialising = True + self.withdrawn = False + elif isinstance(cmd, device.general.Randomise): + self.randomaddr = frame.Frame(24, self._next_random_address()) + elif isinstance(cmd, device.general.Compare): + if ( + self.initialising + and not self.withdrawn + and self.randomaddr.as_integer <= self.searchaddr.as_integer + ): + return _yes + elif isinstance(cmd, device.general.Withdraw): + if self.initialising and self.randomaddr == self.searchaddr: + self.withdrawn = True + elif isinstance(cmd, device.general.SearchAddrH): + self.searchaddr[23:16] = cmd.param + elif isinstance(cmd, device.general.SearchAddrM): + self.searchaddr[15:8] = cmd.param + elif isinstance(cmd, device.general.SearchAddrL): + self.searchaddr[7:0] = cmd.param + elif isinstance(cmd, device.general.ProgramShortAddress): + if self.initialising and self.randomaddr == self.searchaddr: + if cmd.param == 0xFF: + self.shortaddr = None + else: + self.shortaddr = address.DeviceShort(cmd.param) + elif isinstance(cmd, device.general.VerifyShortAddress): + if self.shortaddr is not None and self.shortaddr.address == cmd.param: + return _yes + elif isinstance(cmd, device.general.SetShortAddress): + if self.dtr0 == 0xFF: + self.shortaddr = None + elif isinstance(cmd, device.general.StartQuiescentMode): + self.quiescent = True + elif isinstance(cmd, device.general.StopQuiescentMode): + self.quiescent = False elif isinstance(cmd, device.general.EnableWriteMemory): self.enable_write_memory = True diff --git a/dali/tests/test_device_sequences.py b/dali/tests/test_device_sequences.py index 8df3c1e2..d6ccd626 100644 --- a/dali/tests/test_device_sequences.py +++ b/dali/tests/test_device_sequences.py @@ -19,6 +19,7 @@ from dali.device.helpers import DeviceInstanceTypeMapper, check_bad_rsp from dali.device.pushbutton import InstanceEventFilter as EventFilter_pb from dali.device.sequences import ( + Commissioning, QueryEventFilters, SetEventFilters, SetEventSchemes, @@ -414,3 +415,84 @@ def test_query_input_values_10bit(): except StopIteration as r: ret = r.value assert ret == 434 + + +def _short_addresses(devices: list[fakes.Device]) -> list[int]: + """Return the sorted short addresses of the devices that have one.""" + return sorted( + d.shortaddr.address for d in devices if d.shortaddr is not None + ) + + +def test_device_commissioning(): + devices = [fakes.Device() for _ in range(10)] + bus = fakes.Bus(devices) + + used = bus.run_sequence(Commissioning()) + + assert _short_addresses(devices) == list(range(10)) + assert sorted(a.address for a in used) == list(range(10)) + # Quiescent mode must be exited for every device once addressing completes. + assert all(not d.quiescent for d in devices) + + +def test_device_commissioning_adds_new_without_readdress(): + devices = [fakes.Device() for _ in range(10)] + bus = fakes.Bus(devices) + bus.run_sequence(Commissioning()) + + # Move one device out to address 30 and add 10 fresh unaddressed devices. + # bus.gear is the same list object, so appending once adds them to the bus. + devices[5].shortaddr = DeviceShort(30) + for _ in range(10): + devices.append(fakes.Device()) + + bus.run_sequence(Commissioning()) + + # Existing addresses are kept; new devices fill the lowest free addresses. + assert _short_addresses(devices) == list(range(19)) + [30] + + +def test_device_commissioning_readdress_dry_run_keeps_addresses(): + devices = [fakes.Device() for _ in range(10)] + bus = fakes.Bus(devices) + bus.run_sequence(Commissioning()) + before = _short_addresses(devices) + + bus.run_sequence(Commissioning(readdress=True, dry_run=True)) + + assert _short_addresses(devices) == before + + +def test_device_commissioning_readdress(): + devices = [fakes.Device(DeviceShort(i + 20)) for i in range(10)] + bus = fakes.Bus(devices) + + bus.run_sequence(Commissioning(readdress=True)) + + assert _short_addresses(devices) == list(range(10)) + + +def test_device_commissioning_clash(): + # At least one device picks the same random address as another on the + # first pass, forcing a re-randomise. + randoms = list(range(0, 0xFFFFFF, 0x82000)) + randoms[8] = randoms[4] + devices = [fakes.Device(random_preload=[x]) for x in randoms] + bus = fakes.Bus(devices) + + bus.run_sequence(Commissioning()) + + assert _short_addresses(devices) == list(range(len(devices))) + + +def test_device_commissioning_restricted_addresses(): + devices = [fakes.Device() for _ in range(10)] + bus = fakes.Bus(devices) + + bus.run_sequence(Commissioning(available_addresses=range(10, 15))) + + addressed = _short_addresses(devices) + assert all(10 <= a < 15 for a in addressed) + assert len(addressed) == 5 + assert sum(d.shortaddr is None for d in devices) == 5 From 9401136ae7d946dd75bfeba8bae8209b65298056 Mon Sep 17 00:00:00 2001 From: Markus Becker Date: Thu, 1 Oct 2026 13:17:08 +0200 Subject: [PATCH 16/21] Retry the LUBA connect handshake on a busy bus The initial device-info handshake can time out while the bus is busy (for example when an input device is streaming events), which failed the whole connect. Retry the handshake a few times, draining any late LUBA reply left in the RX queue between attempts so it is not mistaken for the next command's response, and only give up after the configured attempt count. --- dali/driver/serial.py | 33 +++++++++++++++-- dali/tests/test_serial_driver.py | 62 ++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 3 deletions(-) diff --git a/dali/driver/serial.py b/dali/driver/serial.py index 981e4c7e..ee21d1ca 100644 --- a/dali/driver/serial.py +++ b/dali/driver/serial.py @@ -313,6 +313,10 @@ class DriverLubaRs232(DriverSerialBase): timeout_tx_confirm = 1.0 # TX might take some time if the bus is busy timeout_connect = 1.0 timeout_bus_power = 5.0 # Time for the integrated PS to bring the bus up + # The interface can be slow to acknowledge the initial handshake while the + # bus is busy (e.g. an input device streaming events), so retry it a few + # times rather than failing the whole connect on a single timeout. + handshake_attempts = 5 # Lunatone article numbers this driver has been tested against. TESTED_ARTICLE_NUMBERS = frozenset({24166096, 24138246}) @@ -512,6 +516,15 @@ def reset_dali_response(self) -> None: discarded, ) + def reset_luba_response(self) -> None: + """Drop any LUBA command replies left buffered from a prior attempt. + + A reply that arrives after its command's confirm timeout would + otherwise be mistaken for the next command's reply, so it is cleared + before a handshake is retried. + """ + _drain_queue(self._queue_rx_luba_cmd) + @staticmethod def _insert_checksum(in_ints: list[int]) -> None: in_ints[-1] = reduce(xor, in_ints[1:-1]) @@ -1261,9 +1274,23 @@ async def connect(self, *, scan_dev_inst: bool = False) -> None: _LOG.critical(f"Timeout waiting for driver to connect: {exc}") raise - await self._protocol.send_device_info_query() - self._protocol.bus_power = self.bus_power - await self._protocol.send_device_settings() + for attempt in range(1, DriverLubaRs232.handshake_attempts + 1): + try: + await self._protocol.send_device_info_query() + self._protocol.bus_power = self.bus_power + await self._protocol.send_device_settings() + break + except (asyncio.TimeoutError, TimeoutError): + if attempt >= DriverLubaRs232.handshake_attempts: + _LOG.error( + "LUBA handshake timed out after %d attempts", + DriverLubaRs232.handshake_attempts, + ) + raise + _LOG.warning( + "LUBA handshake attempt %d timed out, retrying", attempt + ) + self._protocol.reset_luba_response() self._protocol.dev_inst_map = self.dev_inst_map self._connected.set() diff --git a/dali/tests/test_serial_driver.py b/dali/tests/test_serial_driver.py index 4d765ec1..ded3879f 100644 --- a/dali/tests/test_serial_driver.py +++ b/dali/tests/test_serial_driver.py @@ -53,3 +53,65 @@ async def test_reset_dali_response_drains_all_without_critical(caplog): assert protocol._queue_rx_raw_dali.empty() assert not [r for r in caplog.records if r.levelno >= logging.CRITICAL] assert any("discarded 3 stale" in r.getMessage() for r in caplog.records) + + +def _connecting_driver(monkeypatch, info_side_effect): + """Return a driver whose transport is faked and handshake is scripted.""" + driver = DriverLubaRs232("luba232:/dev/ttyACM0") + protocol = MagicMock() + connected = asyncio.Event() + connected.set() + protocol.connected = connected + protocol.send_device_info_query = AsyncMock(side_effect=info_side_effect) + protocol.send_device_settings = AsyncMock() + protocol.reset_luba_response = MagicMock() + monkeypatch.setattr( + "dali.driver.serial.serialx.create_serial_connection", + AsyncMock(return_value=(MagicMock(), protocol)), + ) + return driver, protocol + + +@pytest.mark.asyncio +async def test_connect_retries_handshake_on_timeout(monkeypatch): + """A busy bus can time out the first handshake frames; connect must retry. + + The interface is slow to acknowledge the info/settings exchange while a + device streams events, so a single timeout should not fail the connect. + """ + driver, protocol = _connecting_driver( + monkeypatch, [asyncio.TimeoutError, asyncio.TimeoutError, None] + ) + + await driver.connect() + + assert driver.is_connected + assert protocol.send_device_info_query.await_count == 3 + assert protocol.send_device_settings.await_count == 1 + assert protocol.reset_luba_response.call_count == 2 + + +@pytest.mark.asyncio +async def test_connect_raises_after_exhausting_handshake_attempts(monkeypatch): + """When every handshake attempt times out, connect must give up and raise.""" + driver, protocol = _connecting_driver(monkeypatch, asyncio.TimeoutError) + + with pytest.raises(asyncio.TimeoutError): + await driver.connect() + + assert not driver.is_connected + attempts = DriverLubaRs232.handshake_attempts + assert protocol.send_device_info_query.await_count == attempts + assert protocol.reset_luba_response.call_count == attempts - 1 + + +@pytest.mark.asyncio +async def test_reset_luba_response_drains_all(caplog): + """Stale LUBA command replies must be cleared before a handshake retry.""" + protocol = DriverLubaRs232.LubaProtocol() + protocol._queue_rx_luba_cmd.put_nowait("stale-1") + protocol._queue_rx_luba_cmd.put_nowait("stale-2") + + protocol.reset_luba_response() + + assert protocol._queue_rx_luba_cmd.empty() From b6958acba85fa87b881523f8d9e63838a378fc4e Mon Sep 17 00:00:00 2001 From: Markus Becker Date: Thu, 1 Oct 2026 13:57:52 +0200 Subject: [PATCH 17/21] Cap control-device autodiscover at 32 instances A device reporting more than 32 instances from QueryNumberOfInstances (a non-conformant device, or a count whose backward frame collided with a spontaneous event) drove the instance loop past instance number 31 and raised ValueError from InstanceNumber, aborting the whole scan. Instance numbers are 0..31 per IEC 62386-103, so a device has at most 32 instances; cap the count defensively. Non-existent instances are already skipped when they do not answer QueryInstanceEnabled. Co-Authored-By: Claude Opus 4.8 --- dali/device/helpers.py | 8 +++++++- dali/tests/test_device_sequences.py | 27 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/dali/device/helpers.py b/dali/device/helpers.py index 0500ecc3..5f736448 100644 --- a/dali/device/helpers.py +++ b/dali/device/helpers.py @@ -162,7 +162,13 @@ def autodiscover( rsp = yield QueryNumberOfInstances(device=addr) if check_bad_rsp(rsp): continue - num_inst = rsp.value + # Instance numbers are 0..31 (IEC 62386-103), so a device can have + # at most 32 instances. Cap the count defensively: a device + # reporting more, or a value corrupted by a colliding event frame, + # would otherwise build an out-of-range InstanceNumber and abort the + # whole scan. Non-existent instances are skipped when they do not + # respond to QueryInstanceEnabled below. + num_inst = min(rsp.value, 32) # For each instance, check it is enabled and then query the type for inst_int in range(num_inst): diff --git a/dali/tests/test_device_sequences.py b/dali/tests/test_device_sequences.py index d6ccd626..9f203317 100644 --- a/dali/tests/test_device_sequences.py +++ b/dali/tests/test_device_sequences.py @@ -13,6 +13,7 @@ QueryEventSchemeResponse, QueryInputValue, QueryInputValueLatch, + QueryNumberOfInstances, QueryResolution, SetEventFilter, ) @@ -162,6 +163,32 @@ def counting_send(cmd): assert probes.count(5) == 1 +def test_device_autodiscover_caps_instance_count(fakes_bus): + """A device reporting more than 32 instances does not abort the scan. + + Instance numbers are 0..31, so a count above 32 (a non-conformant device, + or a value whose backward frame collided with an event) must not build an + out-of-range InstanceNumber and crash the whole scan; the count is capped + and the device's real instances are still enumerated. + """ + dev_inst_map = DeviceInstanceTypeMapper() + real_send = fakes_bus.send + + def send_with_bad_count(cmd): + if isinstance(cmd, QueryNumberOfInstances) and ( + getattr(cmd.destination, "address", None) == 0 + ): + return NumericResponse(BackwardFrame(255)) + return real_send(cmd) + + fakes_bus.send = send_with_bad_count + fakes_bus.run_sequence(dev_inst_map.autodiscover()) + + # Device 0's four real instances are still found despite the bogus count. + assert sum(1 for addr, _instance in dev_inst_map.mapping if addr == 0) == 4 + assert len(dev_inst_map.mapping) == 12 + + def test_device_query_event_scheme(fakes_bus): rsp = fakes_bus.send(QueryEventScheme(DeviceShort(1), InstanceNumber(1))) assert isinstance(rsp, QueryEventSchemeResponse) From 91853c506bf61524f7d3b6fcdfb53a7030bdfb5d Mon Sep 17 00:00:00 2001 From: Markus Becker Date: Thu, 1 Oct 2026 14:18:56 +0200 Subject: [PATCH 18/21] Add vendor-extension unit codes 55 (dB(A)) and 56 The IEC 62386-306 Annex A, Table A.1 unit-of-measurement list ends at 53 (parts per million). Real hardware (Lunatone sonuSense) reports unit codes beyond that: 55 for A-weighted sound pressure level and 56 for a manufacturer-defined probability instance. Decode them instead of surfacing them as unknown values, clearly marked as non-306 extensions. Co-Authored-By: Claude Opus 4.8 --- dali/device/general_sensor.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dali/device/general_sensor.py b/dali/device/general_sensor.py index 1ffe1bdb..064503f0 100644 --- a/dali/device/general_sensor.py +++ b/dali/device/general_sensor.py @@ -119,6 +119,10 @@ class UnitOfMeasurement(IntEnum): PART_PER_THOUSAND = 51 PART_PER_TEN_THOUSAND = 52 PARTS_PER_MILLION = 53 + # Values below are not defined by Part 306 Annex A, Table A.1 (which ends + # at 53). They are vendor extensions observed on real hardware. + DECIBEL_A = 55 # dB(A), A-weighted sound pressure level + PROBABILITY = 56 class QuantityName(IntEnum): From efb12b6752350bc2010582c53042f67191f7581c Mon Sep 17 00:00:00 2001 From: Markus Becker Date: Fri, 2 Oct 2026 14:36:46 +0200 Subject: [PATCH 19/21] Add emergency lighting (DT1) memory bank 208 and query helpers Implements the IEC 62386-202 self-contained emergency control gear variable definitions and sequence helpers consumed by application controllers: - dali/memory/emergency.py: memory bank 208 (Table 18) with the control gear temperatures, battery charge/maintenance powers, rated duration, recharge/function-test times, and the full set of failure, mode, test and lamp counters. - dali/gear/sequences.py: QueryEmergencyInformation bundles the DT1 status and measurement queries into a single sequence (so the required ENABLE DEVICE TYPE 1 prefix is injected), and EmergencyCommand issues a single DT1 control command. - Fake gear emergency support plus tests for the bank, the information sequence and the control-command helper. Co-Authored-By: Claude Opus 4.8 --- dali/gear/sequences.py | 110 ++++++++++++ dali/memory/__init__.py | 1 + dali/memory/emergency.py | 334 +++++++++++++++++++++++++++++++++++ dali/tests/fakes.py | 50 ++++++ dali/tests/test_emergency.py | 67 +++++++ dali/tests/test_memory.py | 79 ++++++++- 6 files changed, 639 insertions(+), 2 deletions(-) create mode 100644 dali/memory/emergency.py create mode 100644 dali/tests/test_emergency.py diff --git a/dali/gear/sequences.py b/dali/gear/sequences.py index 73a74145..b25c46d3 100644 --- a/dali/gear/sequences.py +++ b/dali/gear/sequences.py @@ -3,6 +3,7 @@ """ from __future__ import annotations +from collections import namedtuple from typing import Generator, Optional from dali import command @@ -16,6 +17,18 @@ SetTemporaryColourTemperature, StoreColourTemperatureTcLimit, ) +from dali.gear.emergency import ( + QueryBatteryCharge, + QueryDurationTestResult, + QueryEmergencyFailureStatus, + QueryEmergencyFeatures, + QueryEmergencyLevel, + QueryEmergencyMode, + QueryEmergencyStatus, + QueryLampEmergencyTime, + QueryLampTotalOperationTime, + QueryRatedDuration, +) from dali.gear.general import DTR0, DTR1, DTR2, QueryActualLevel, QueryContentDTR0 from dali.gear.led import QueryDimmingCurve @@ -175,3 +188,100 @@ def SetDT8TcLimit( yield DTR1(tc_bytes[1]) yield DTR2(what_limit) yield StoreColourTemperatureTcLimit(address) + + +EmergencyInformation = namedtuple( + "EmergencyInformation", + [ + "emergency_mode", + "emergency_features", + "emergency_failure_status", + "emergency_status", + "battery_charge", + "emergency_level", + "duration_test_result", + "lamp_emergency_time", + "lamp_total_operation_time", + "rated_duration", + ], +) + + +def _bitmap_byte(response: Optional[command.Response]) -> Optional[int]: + """Return the raw byte of a bitmap response, or None if unavailable.""" + if response is None: + return None + raw = response.raw_value + if raw is None or raw.error: + return None + return raw.as_integer + + +def _numeric(response: Optional[command.Response]) -> Optional[int]: + """Return the integer value of a numeric response, or None for MASK, + a missing answer or a framing error.""" + if response is None: + return None + value = response.value + return value if isinstance(value, int) else None + + +def QueryEmergencyInformation( + address: GearShort, +) -> Generator[command.Command, Optional[command.Response], EmergencyInformation]: + """Read the DT1 (IEC 62386-202) status and measurement values of an + emergency control gear in a single sequence. + + Running it as a sequence ensures the required "ENABLE DEVICE TYPE 1" + command is sent before each application-extended command, so the gear + answers. The returned values are plain integers (or None where the gear + reports MASK or does not answer); the four status bytes are returned as + raw bytes so the caller can decode individual bits. + + :param address: GearShort address to query + :return: an EmergencyInformation namedtuple + """ + # Although the proper types are expected, ints are common enough for + # addresses and their meaning is unambiguous in this context + if isinstance(address, int): + address = GearShort(address) + + return EmergencyInformation( + emergency_mode=_bitmap_byte((yield QueryEmergencyMode(address))), + emergency_features=_bitmap_byte((yield QueryEmergencyFeatures(address))), + emergency_failure_status=_bitmap_byte( + (yield QueryEmergencyFailureStatus(address)) + ), + emergency_status=_bitmap_byte((yield QueryEmergencyStatus(address))), + battery_charge=_numeric((yield QueryBatteryCharge(address))), + emergency_level=_numeric((yield QueryEmergencyLevel(address))), + duration_test_result=_numeric((yield QueryDurationTestResult(address))), + lamp_emergency_time=_numeric((yield QueryLampEmergencyTime(address))), + lamp_total_operation_time=_numeric( + (yield QueryLampTotalOperationTime(address)) + ), + rated_duration=_numeric((yield QueryRatedDuration(address))), + ) + + +def EmergencyCommand( + address: GearShort, + command_class: type[command.Command], +) -> Generator[command.Command, Optional[command.Response], Optional[command.Response]]: + """Issue a single DT1 (IEC 62386-202) application-extended command. + + Running it as a sequence ensures the required "ENABLE DEVICE TYPE 1" + command is sent immediately beforehand, so the gear acts on the command. + Use this for the emergency control commands (tests, resets, mode changes) + which otherwise are ignored if sent without the device-type prefix. + + :param address: GearShort address to command + :param command_class: an emergency command class taking a single address + :return: the command response, or None + """ + # Although the proper types are expected, ints are common enough for + # addresses and their meaning is unambiguous in this context + if isinstance(address, int): + address = GearShort(address) + + return (yield command_class(address)) diff --git a/dali/memory/__init__.py b/dali/memory/__init__.py index 954283df..c9591db0 100644 --- a/dali/memory/__init__.py +++ b/dali/memory/__init__.py @@ -5,4 +5,5 @@ 'energy', 'diagnostics', 'maintenance', + 'emergency', ] diff --git a/dali/memory/emergency.py b/dali/memory/emergency.py new file mode 100644 index 00000000..3512f740 --- /dev/null +++ b/dali/memory/emergency.py @@ -0,0 +1,334 @@ +"""Memory bank 208 from IEC 62386-202 (self-contained emergency lighting). + +Section 9.15.6, Table 18: information about the control gear, lamp and +battery of a device-type-1 (emergency) control gear. +""" +from .location import ( + FixedScaleNumericValue, + MemoryBank, + MemoryLocation, + MemoryRange, + MemoryType, + NumericValue, + TemperatureValue, +) + +# Table 18 lists 0x32 as the default last addressable location but also +# defines a value at 0x33 ("Performed on this battery"); declare the bank +# up to 0x33 so that value is treated as addressable when a device reports +# it. read_all() always honours the device's own reported last address. +BANK_208 = MemoryBank(208, 0x33, has_lock=True, has_latch=True) + + +class MemoryBankVersion(NumericValue): + """Version of memory bank 208.""" + bank = BANK_208 + locations = MemoryLocation(address=0x03, default=0x01, type_=MemoryType.ROM) + + +class ControlGearMaxReferenceTemperature(TemperatureValue): + """Control gear maximum reference temperature, typically at the Tc point.""" + bank = BANK_208 + locations = MemoryLocation(address=0x04, type_=MemoryType.ROM) + mask_supported = True + min_value = 0 + max_value = 0xFD + + +class ControlGearTemperature(TemperatureValue): + """Internal temperature of the control gear.""" + bank = BANK_208 + locations = MemoryLocation(address=0x05, type_=MemoryType.RAM_RO) + mask_supported = True + tmask_supported = True + min_value = 0 + max_value = 0xFD + + +class MinControlGearTemperatureTotal(TemperatureValue): + """Minimum measured gear temperature (total).""" + bank = BANK_208 + locations = MemoryLocation(address=0x06, type_=MemoryType.NVM_RO) + mask_supported = True + tmask_supported = True + min_value = 0 + max_value = 0xFD + + +class MaxControlGearTemperatureTotal(TemperatureValue): + """Maximum measured gear temperature (total).""" + bank = BANK_208 + locations = MemoryLocation(address=0x07, type_=MemoryType.NVM_RO) + mask_supported = True + tmask_supported = True + min_value = 0 + max_value = 0xFD + + +class MinControlGearTemperatureCurrentBattery(TemperatureValue): + """Minimum measured gear temperature (current battery).""" + bank = BANK_208 + locations = MemoryLocation(address=0x08, type_=MemoryType.NVM_RO) + mask_supported = True + tmask_supported = True + min_value = 0 + max_value = 0xFD + + +class MaxControlGearTemperatureCurrentBattery(TemperatureValue): + """Maximum measured gear temperature (current battery).""" + bank = BANK_208 + locations = MemoryLocation(address=0x09, type_=MemoryType.NVM_RO) + mask_supported = True + tmask_supported = True + min_value = 0 + max_value = 0xFD + + +class AveragePowerDuringCharging(FixedScaleNumericValue): + """Average power during battery charging; 0xFD means 25.3 W or greater.""" + bank = BANK_208 + unit = 'W' + scaling_factor = 0.1 + locations = MemoryLocation(address=0x0A, type_=MemoryType.NVM_RO) + max_value = 0xFD + + +class AveragePowerDuringMaintenance(FixedScaleNumericValue): + """Average power during battery charge maintenance; 0xFD means 25.3 W or + greater.""" + bank = BANK_208 + unit = 'W' + scaling_factor = 0.1 + locations = MemoryLocation(address=0x0B, type_=MemoryType.NVM_RO) + max_value = 0xFD + + +class RatedDuration(FixedScaleNumericValue): + """Rated duration time in minutes (stored in units of 2 min).""" + bank = BANK_208 + unit = 'min' + scaling_factor = 2 + locations = MemoryLocation(address=0x0C, type_=MemoryType.NVM_RO) + + +class FunctionTestTime(NumericValue): + """Nominal function test time in seconds; 0xFD means 253 s or greater.""" + bank = BANK_208 + unit = 's' + locations = MemoryLocation(address=0x0D, type_=MemoryType.NVM_RO) + min_value = 5 + max_value = 0xFD + + +class BatteryRechargeTime(FixedScaleNumericValue): + """Nominal battery recharge time in minutes (stored in units of 10 min); + 0xFD means 2530 min or greater.""" + bank = BANK_208 + unit = 'min' + scaling_factor = 10 + locations = MemoryLocation(address=0x0E, type_=MemoryType.NVM_RO) + min_value = 1 + max_value = 0xFD + + +class BatteryFailureCounter(NumericValue): + """Battery failure counter.""" + bank = BANK_208 + locations = MemoryLocation(address=0x0F, type_=MemoryType.NVM_RO) + mask_supported = True + max_value = 0xFD + + +class BatteryCutOffCounter(NumericValue): + """Count of transitions from below to above battery cut-off voltage; + 0xFD means 253 or greater.""" + bank = BANK_208 + locations = MemoryLocation(address=0x10, type_=MemoryType.NVM_RO) + max_value = 0xFD + + +class LampCutOffCounterTotal(NumericValue): + """Count of transitions from above to below lamp cut-off voltage (total).""" + bank = BANK_208 + locations = MemoryLocation(address=0x11, type_=MemoryType.NVM_RO) + max_value = 0xFD + + +class LampCutOffCounterCurrentBattery(NumericValue): + """Count of transitions from above to below lamp cut-off voltage + (current battery).""" + bank = BANK_208 + locations = MemoryLocation(address=0x12, type_=MemoryType.NVM_RO) + max_value = 0xFD + + +class LampEmergencyTimeTotal(NumericValue): + """Lamp emergency time (total) in minutes; 0xFFFFFD means 16777213 min or + greater.""" + bank = BANK_208 + unit = 'min' + locations = MemoryRange(start=0x13, end=0x15, type_=MemoryType.NVM_RO) + max_value = 0xFFFFFD + + +class LampEmergencyTimeCurrentBattery(NumericValue): + """Lamp emergency time (current battery) in minutes; 0xFFFFFD means + 16777213 min or greater.""" + bank = BANK_208 + unit = 'min' + locations = MemoryRange(start=0x16, end=0x18, type_=MemoryType.NVM_RO) + max_value = 0xFFFFFD + + +class BatteryConnectedTimeTotal(NumericValue): + """Battery connected time (total) in days; 0xFFFD means 65533 days or + greater.""" + bank = BANK_208 + unit = 'd' + locations = MemoryRange(start=0x19, end=0x1A, type_=MemoryType.NVM_RO) + max_value = 0xFFFD + + +class BatteryConnectedTimeCurrentBattery(NumericValue): + """Battery connected time (current battery) in days; 0xFFFD means 65533 + days or greater.""" + bank = BANK_208 + unit = 'd' + locations = MemoryRange(start=0x1B, end=0x1C, type_=MemoryType.NVM_RO) + max_value = 0xFFFD + + +class CircuitFailureCounter(NumericValue): + """Counts transitions of failure status bit 0 (circuit failure).""" + bank = BANK_208 + locations = MemoryLocation(address=0x1D, type_=MemoryType.NVM_RO) + max_value = 0xFD + + +class BatteryDurationFailureCounter(NumericValue): + """Counts transitions of failure status bit 1 (battery duration failure).""" + bank = BANK_208 + locations = MemoryLocation(address=0x1E, type_=MemoryType.NVM_RO) + max_value = 0xFD + + +class BatteryFailureStatusCounter(NumericValue): + """Counts transitions of failure status bit 2 (battery failure).""" + bank = BANK_208 + locations = MemoryLocation(address=0x1F, type_=MemoryType.NVM_RO) + max_value = 0xFD + + +class EmergencyLampFailureCounter(NumericValue): + """Counts transitions of failure status bit 3 (emergency lamp failure).""" + bank = BANK_208 + locations = MemoryLocation(address=0x20, type_=MemoryType.NVM_RO) + max_value = 0xFD + + +class FunctionTestMaxDelayExceededCounter(NumericValue): + """Counts transitions of failure status bit 4.""" + bank = BANK_208 + locations = MemoryLocation(address=0x21, type_=MemoryType.NVM_RO) + max_value = 0xFD + + +class DurationTestMaxDelayExceededCounter(NumericValue): + """Counts transitions of failure status bit 5.""" + bank = BANK_208 + locations = MemoryLocation(address=0x22, type_=MemoryType.NVM_RO) + max_value = 0xFD + + +class FunctionTestFailedCounterTotal(NumericValue): + """Counts transitions of failure status bit 6 (total); 0xFFFD means 65533 + or greater.""" + bank = BANK_208 + locations = MemoryRange(start=0x23, end=0x24, type_=MemoryType.NVM_RO) + max_value = 0xFFFD + + +class DurationTestFailedCounterTotal(NumericValue): + """Counts transitions of failure status bit 7 (total); 0xFD means 253 or + greater.""" + bank = BANK_208 + locations = MemoryLocation(address=0x25, type_=MemoryType.NVM_RO) + max_value = 0xFD + + +class FunctionTestFailedCounterCurrentBattery(NumericValue): + """Counts transitions of failure status bit 6 (current battery); 0xFFFD + means 65533 or greater.""" + bank = BANK_208 + locations = MemoryRange(start=0x26, end=0x27, type_=MemoryType.NVM_RO) + max_value = 0xFFFD + + +class DurationTestFailedCounterCurrentBattery(NumericValue): + """Counts transitions of failure status bit 7 (current battery); 0xFD means + 253 or greater.""" + bank = BANK_208 + locations = MemoryLocation(address=0x28, type_=MemoryType.NVM_RO) + max_value = 0xFD + + +class StartFunctionTestCounter(NumericValue): + """Counts transitions into function test mode; 0xFFFD means 65533 or + greater.""" + bank = BANK_208 + locations = MemoryRange(start=0x29, end=0x2A, type_=MemoryType.NVM_RO) + max_value = 0xFFFD + + +class StartDurationTestCounterTotal(NumericValue): + """Counts transitions into duration test mode (total); 0xFD means 253 or + greater.""" + bank = BANK_208 + locations = MemoryLocation(address=0x2B, type_=MemoryType.NVM_RO) + max_value = 0xFD + + +class StartDurationTestCounterCurrentBattery(NumericValue): + """Counts transitions into duration test mode (current battery); 0xFD means + 253 or greater.""" + bank = BANK_208 + locations = MemoryLocation(address=0x2C, type_=MemoryType.NVM_RO) + max_value = 0xFD + + +class RestModeCounter(NumericValue): + """Counts transitions into rest mode; 0xFFFD means 65533 or greater.""" + bank = BANK_208 + locations = MemoryRange(start=0x2D, end=0x2E, type_=MemoryType.NVM_RO) + max_value = 0xFFFD + + +class EmergencyModeCounterTotal(NumericValue): + """Counts transitions into emergency mode (total); 0xFFFD means 65533 or + greater.""" + bank = BANK_208 + locations = MemoryRange(start=0x2F, end=0x30, type_=MemoryType.NVM_RO) + max_value = 0xFFFD + + +class EmergencyModeCounterCurrentBattery(NumericValue): + """Counts transitions into emergency mode (current battery); 0xFFFD means + 65533 or greater.""" + bank = BANK_208 + locations = MemoryRange(start=0x31, end=0x32, type_=MemoryType.NVM_RO) + max_value = 0xFFFD + + +class PerformedOnThisBattery(NumericValue): + """Bitmap of modes/tests that occurred on the current battery. + + Bit 0: rest mode was active at least once + Bit 1: battery is successfully charging or maintained at full charge + Bit 2: emergency mode was active at least once + Bit 3: extended emergency mode was active at least once + Bit 4: function test was active at least once + Bit 5: duration test was active at least once + """ + bank = BANK_208 + locations = MemoryLocation(address=0x33, type_=MemoryType.RAM_RO) diff --git a/dali/tests/fakes.py b/dali/tests/fakes.py index 8bb317bd..dda87bc2 100644 --- a/dali/tests/fakes.py +++ b/dali/tests/fakes.py @@ -130,6 +130,19 @@ def __init__(self, shortaddr=None, groups=set(), self.ct_mired_min = 153 self.ct_mired_max = 370 self.physical_minimum = 1 + # DT1 (IEC 62386-202) emergency lighting state; only used when 1 is + # in devicetypes. Defaults describe a healthy type-A gear in normal + # mode with a fully charged battery. + self.emergency_mode = 0b00000010 # normal mode + self.emergency_features = 0b00000011 # integral + maintained (type A) + self.emergency_failure_status = 0x00 + self.emergency_status = 0b00001000 # battery fully charged + self.battery_charge = 254 + self.emergency_level = 254 + self.duration_test_result = 60 + self.lamp_emergency_time = 5 + self.lamp_total_operation_time = 100 + self.rated_duration = 90 self.memory_banks = {} for fake_bank in memory_banks: bank_number = fake_bank.bank.address @@ -438,6 +451,43 @@ def send(self, cmd): elif isinstance(cmd, gear.colour.QueryExtendedVersionNumber): return 2 + # Handle DT1 emergency lighting commands; device type decoding has + # already been handled + if isinstance(cmd, gear.emergency._EmergencyLightingCommand): + # Only handle if this Gear instance has been declared as a DT1 type + if 1 not in self.devicetypes: + return + if isinstance(cmd, gear.emergency.QueryEmergencyMode): + return self.emergency_mode + elif isinstance(cmd, gear.emergency.QueryEmergencyFeatures): + return self.emergency_features + elif isinstance(cmd, gear.emergency.QueryEmergencyFailureStatus): + return self.emergency_failure_status + elif isinstance(cmd, gear.emergency.QueryEmergencyStatus): + return self.emergency_status + elif isinstance(cmd, gear.emergency.QueryBatteryCharge): + return self.battery_charge + elif isinstance(cmd, gear.emergency.QueryEmergencyLevel): + return self.emergency_level + elif isinstance(cmd, gear.emergency.QueryDurationTestResult): + return self.duration_test_result + elif isinstance(cmd, gear.emergency.QueryLampEmergencyTime): + return self.lamp_emergency_time + elif isinstance(cmd, gear.emergency.QueryLampTotalOperationTime): + return self.lamp_total_operation_time + elif isinstance(cmd, gear.emergency.QueryRatedDuration): + return self.rated_duration + elif isinstance(cmd, gear.emergency.StartFunctionTest): + self.emergency_status |= 0b00010000 # function test pending + elif isinstance(cmd, gear.emergency.StartDurationTest): + self.emergency_status |= 0b00100000 # duration test pending + elif isinstance(cmd, gear.emergency.StopTest): + self.emergency_status &= ~0b00110000 # clear both pending bits + elif isinstance(cmd, gear.emergency.Inhibit): + self.emergency_status |= 0b00000001 # inhibit mode + elif isinstance(cmd, gear.emergency.ReLightResetInhibit): + self.emergency_status &= ~0b00000001 + # Valid bank 0 ROM contents compatible with IEC 62386-103 class FakeDeviceBank0(FakeMemoryBank): diff --git a/dali/tests/test_emergency.py b/dali/tests/test_emergency.py new file mode 100644 index 00000000..03525b0d --- /dev/null +++ b/dali/tests/test_emergency.py @@ -0,0 +1,67 @@ +from dali.frame import BackwardFrame +from dali.gear.emergency import ( + QueryEmergencyFailureStatusResponse, + QueryEmergencyModeResponse, + QueryEmergencyStatusResponse, + StartFunctionTest, + StopTest, +) +from dali.gear.sequences import EmergencyCommand, QueryEmergencyInformation +from dali.tests import fakes + + +def test_emergency_status_decoding(): + rsp = QueryEmergencyStatusResponse(BackwardFrame(0b00001010)) + assert rsp.inhibit_mode == 0 + assert rsp.function_test_done_and_result_valid == 1 + assert rsp.duration_test_done_and_result_valid == 0 + assert rsp.battery_fully_charged == 1 + assert set(rsp.status) == { + "function test done and result valid", + "battery fully charged", + } + + +def test_emergency_failure_decoding(): + rsp = QueryEmergencyFailureStatusResponse(BackwardFrame(0b00000101)) + assert rsp.circuit_failure == 1 + assert rsp.battery_duration_failure == 0 + assert rsp.battery_failure == 1 + + +def test_emergency_mode_property(): + # Only the "emergency mode" bit set + rsp = QueryEmergencyModeResponse(BackwardFrame(0b00000100)) + assert rsp.mode == "emergency mode" + + +def test_query_emergency_information(): + bus = fakes.Bus([fakes.Gear(shortaddr=0, devicetypes=[1])]) + info = bus.run_sequence(QueryEmergencyInformation(0)) + assert info.emergency_mode == 0b00000010 + assert info.emergency_features == 0b00000011 + assert info.emergency_failure_status == 0 + assert info.emergency_status == 0b00001000 + assert info.battery_charge == 254 + assert info.emergency_level == 254 + assert info.duration_test_result == 60 + assert info.lamp_emergency_time == 5 + assert info.lamp_total_operation_time == 100 + assert info.rated_duration == 90 + + +def test_query_emergency_information_mask(): + gear = fakes.Gear(shortaddr=0, devicetypes=[1]) + gear.battery_charge = 255 # MASK + bus = fakes.Bus([gear]) + info = bus.run_sequence(QueryEmergencyInformation(0)) + assert info.battery_charge is None + + +def test_emergency_command(): + gear = fakes.Gear(shortaddr=0, devicetypes=[1]) + bus = fakes.Bus([gear]) + bus.run_sequence(EmergencyCommand(0, StartFunctionTest)) + assert gear.emergency_status & 0b00010000 + bus.run_sequence(EmergencyCommand(0, StopTest)) + assert not gear.emergency_status & 0b00110000 diff --git a/dali/tests/test_memory.py b/dali/tests/test_memory.py index 4cc0dc31..47dae877 100644 --- a/dali/tests/test_memory.py +++ b/dali/tests/test_memory.py @@ -12,7 +12,7 @@ ) from dali.frame import BackwardFrame from dali.gear.general import DTR0, DTR1, ReadMemoryLocation -from dali.memory import diagnostics, energy, info, maintenance, oem +from dali.memory import diagnostics, emergency, energy, info, maintenance, oem from dali.memory.location import FlagValue, MemoryBank, NumericValue from dali.tests import fakes @@ -157,6 +157,51 @@ class FakeBank207(fakes.FakeMemoryBank): ] +# Self-contained emergency control gear information (IEC 62386-202) +class FakeBank208(fakes.FakeMemoryBank): + bank = emergency.BANK_208 + initial_contents = [ + 0x33, None, 0xff, + 0x01, # Version 1 + 85, # Max reference temperature 25°C + 90, # Control gear temperature 30°C + 50, # Min gear temperature (total) -10°C + 100, # Max gear temperature (total) 40°C + 60, # Min gear temperature (current battery) 0°C + 95, # Max gear temperature (current battery) 35°C + 100, # Average power during charging 10.0W + 50, # Average power during maintenance 5.0W + 90, # Rated duration 180 min + 10, # Function test time 10s + 12, # Battery recharge time 120 min + 0, # Battery failure counter + 3, # Battery cut-off counter + 1, # Lamp cut-off counter (total) + 0, # Lamp cut-off counter (current battery) + 0x00, 0x03, 0xe8, # Lamp emergency time (total) 1000 min + 0x00, 0x01, 0xf4, # Lamp emergency time (current battery) 500 min + 0x01, 0x6d, # Battery connected time (total) 365 days + 0x00, 0x1e, # Battery connected time (current battery) 30 days + 0, # Circuit failure counter + 1, # Battery duration failure counter + 2, # Battery failure status counter + 0, # Emergency lamp failure counter + 0, # Function test max delay exceeded counter + 0, # Duration test max delay exceeded counter + 0x00, 0x05, # Function test failed counter (total) 5 + 1, # Duration test failed counter (total) + 0x00, 0x02, # Function test failed counter (current battery) 2 + 0, # Duration test failed counter (current battery) + 0x00, 0x32, # Start function test counter 50 + 10, # Start duration test counter (total) + 3, # Start duration test counter (current battery) + 0x00, 0x07, # Rest mode counter 7 + 0x00, 0x14, # Emergency mode counter (total) 20 + 0x00, 0x04, # Emergency mode counter (current battery) 4 + 0x0a, # Performed on this battery bitmap + ] + + # Invalid / special memory bank contents class InvalidBank1(fakes.FakeMemoryBank): """Strange version of bank 1 @@ -314,7 +359,8 @@ def setUp(self): self.bus = fakes.Bus([ fakes.Gear(GearShort(0), memory_banks=( fakes.FakeBank0, FakeBank1, FakeBank202, FakeBank203, - FakeBank204, FakeBank205, FakeBank206, FakeBank207)), + FakeBank204, FakeBank205, FakeBank206, FakeBank207, + FakeBank208)), fakes.Gear(GearShort(1), memory_banks=( fakes.FakeBank0, InvalidBank1, InvalidBank202, LatchTestBank203, InvalidBank207)), @@ -542,6 +588,35 @@ def test_invalid_oem_strings(self): self.assertEqual(self.bus.run_sequence( oem.LuminaireIdentification.read(0)), FlagValue.Invalid) + def test_emergency(self): + self._test_value(emergency.MemoryBankVersion, 1) + self._test_value(emergency.ControlGearMaxReferenceTemperature, 25) + self._test_value(emergency.ControlGearTemperature, 30) + self._test_value(emergency.MinControlGearTemperatureTotal, -10) + self._test_value(emergency.MaxControlGearTemperatureTotal, 40) + self._test_value(emergency.AveragePowerDuringCharging, 10.0) + self._test_value(emergency.AveragePowerDuringMaintenance, 5.0) + self._test_value(emergency.RatedDuration, 180) + self._test_value(emergency.FunctionTestTime, 10) + self._test_value(emergency.BatteryRechargeTime, 120) + self._test_value(emergency.BatteryCutOffCounter, 3) + self._test_value(emergency.LampEmergencyTimeTotal, 1000) + self._test_value(emergency.LampEmergencyTimeCurrentBattery, 500) + self._test_value(emergency.BatteryConnectedTimeTotal, 365) + self._test_value(emergency.BatteryDurationFailureCounter, 1) + self._test_value(emergency.BatteryFailureStatusCounter, 2) + self._test_value(emergency.FunctionTestFailedCounterTotal, 5) + self._test_value(emergency.StartFunctionTestCounter, 50) + self._test_value(emergency.EmergencyModeCounterTotal, 20) + self._test_value(emergency.PerformedOnThisBattery, 0x0a) + + def test_emergency_read_all(self): + values = self.bus.run_sequence(emergency.BANK_208.read_all(0)) + self.assertEqual(values[emergency.RatedDuration], 180) + self.assertEqual(values[emergency.ControlGearTemperature], 30) + self.assertEqual(values[emergency.LampEmergencyTimeTotal], 1000) + self.assertEqual(values[emergency.StartFunctionTestCounter], 50) + def test_info(self): # Default bank 0 contents from fakes.py self._test_value(info.GTIN, 1234567654321) From 496069cd78843e45f06ac8293d256a1dbf961984 Mon Sep 17 00:00:00 2001 From: Markus Becker Date: Mon, 5 Oct 2026 10:40:25 +0200 Subject: [PATCH 20/21] Drop Python 3.8 and 3.9 from CI and require Python 3.10+ The driver-serial and test extras now depend on serialx, which requires Python >= 3.10. Installing the test extra therefore fails on 3.8 and 3.9, breaking the "Install dependencies" step of the test matrix. Drop those interpreters from the matrix and raise python_requires to match. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/run-tests.yml | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index c73cd871..af85f06c 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] + python-version: ['3.10', '3.11', '3.12'] steps: - uses: actions/checkout@v3 diff --git a/setup.cfg b/setup.cfg index 853dae75..44f1604a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -20,7 +20,7 @@ classifiers = Programming Language :: Python :: 3 [options] -python_requires = >=3.7 +python_requires = >=3.10 packages = dali dali.device From 1ae2754a3926e4d8b76d3ca890872214c4511081 Mon Sep 17 00:00:00 2001 From: Markus Becker Date: Mon, 5 Oct 2026 10:05:45 +0200 Subject: [PATCH 21/21] Add py.typed markers and annotate the public API surface Ship PEP 561 py.typed markers for every public subpackage (dali, device, driver, gear, memory) and include them in the built distribution via MANIFEST.in and include_package_data, so downstream consumers' type checkers follow python-dali as a typed dependency. Annotate the public signatures the consumers call: Response/Command in command.py, the address constructors, the gear/device command bases, the commissioning and query sequences, memory bank read_all, and the typed exceptions. Signatures only; no behavior change. Add `from __future__ import annotations` where PEP 604 unions require it on the 3.7 floor, and fix the malformed dict[(int, int), int] annotation. Co-Authored-By: Claude Opus 4.8 --- MANIFEST.in | 1 + dali/address.py | 8 +++++--- dali/command.py | 9 ++++++--- dali/device/general.py | 8 +++++--- dali/device/helpers.py | 10 ++++++---- dali/device/occupancy.py | 2 +- dali/device/py.typed | 0 dali/driver/py.typed | 0 dali/exceptions.py | 6 ++++-- dali/gear/general.py | 8 +++++--- dali/gear/py.typed | 0 dali/gear/sequences.py | 4 ++-- dali/memory/location.py | 10 ++++++++-- dali/memory/py.typed | 0 dali/py.typed | 0 dali/sequences.py | 28 ++++++++++++++++++++++------ setup.cfg | 1 + 17 files changed, 66 insertions(+), 29 deletions(-) create mode 100644 MANIFEST.in create mode 100644 dali/device/py.typed create mode 100644 dali/driver/py.typed create mode 100644 dali/gear/py.typed create mode 100644 dali/memory/py.typed create mode 100644 dali/py.typed diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..e926f443 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +recursive-include dali py.typed diff --git a/dali/address.py b/dali/address.py index c064b161..9fec190e 100644 --- a/dali/address.py +++ b/dali/address.py @@ -20,6 +20,8 @@ 7.2.2. Decoding of event messages is currently not implemented. """ +from __future__ import annotations + from dali.exceptions import IncompatibleFrame @@ -261,7 +263,7 @@ class GearShort(GearAddress): address. """ - def __init__(self, address): + def __init__(self, address: int) -> None: if not isinstance(address, int): raise ValueError("address must be an integer") if address < 0 or address > 63: @@ -305,7 +307,7 @@ class DeviceShort(DeviceAddress): address. """ - def __init__(self, address: int): + def __init__(self, address: int) -> None: if not isinstance(address, int): raise ValueError("address must be an integer") if address < 0 or address > 63: @@ -379,7 +381,7 @@ def __str__(self): class _AddressedInstance(Instance): _flags = None - def __init__(self, value): + def __init__(self, value: int) -> None: if not isinstance(value, int): raise ValueError("value must be an integer") if value < 0 or value > 31: diff --git a/dali/command.py b/dali/command.py index e4a49467..0d18b654 100644 --- a/dali/command.py +++ b/dali/command.py @@ -1,6 +1,9 @@ """Declaration of base types for dali commands and their responses.""" +from __future__ import annotations + from enum import IntEnum +from typing import Any from dali import address from dali import frame from dali.exceptions import MissingResponse @@ -46,17 +49,17 @@ class Response: _expected = False _error_acceptable = False - def __init__(self, val): + def __init__(self, val: frame.BackwardFrame | None) -> None: if val is not None and not isinstance(val, frame.BackwardFrame): raise TypeError("Response must be passed None or a BackwardFrame") self._value = val @property - def raw_value(self): + def raw_value(self) -> frame.BackwardFrame | None: return self._value @property - def value(self): + def value(self) -> Any: if self._value is None and self._expected: raise MissingResponse() if self._value and self._value.error and not self._error_acceptable: diff --git a/dali/device/general.py b/dali/device/general.py index 24ac2f54..d11f3bb7 100644 --- a/dali/device/general.py +++ b/dali/device/general.py @@ -128,7 +128,7 @@ class _StandardDeviceCommand(_DeviceCommand): """ _opcode = None - def __init__(self, device): + def __init__(self, device: address.Address | int) -> None: if self._opcode is None: raise NotImplementedError @@ -514,7 +514,9 @@ class _StandardInstanceCommand(_DeviceCommand): _opcode = None - def __init__(self, device, instance): + def __init__( + self, device: address.Address | int, instance: address.Instance + ) -> None: if self._opcode is None: raise NotImplementedError @@ -784,7 +786,7 @@ def __str__(self): class _SpecialDeviceCommandOneParam(_SpecialDeviceCommand): - def __init__(self, param): + def __init__(self, param: int) -> None: if not isinstance(param, int): raise ValueError("parameter must be an integer") if param < 0 or param > 255: diff --git a/dali/device/helpers.py b/dali/device/helpers.py index 5f736448..cdc3a8e8 100644 --- a/dali/device/helpers.py +++ b/dali/device/helpers.py @@ -69,19 +69,21 @@ class DeviceInstanceTypeMapper: through the `from_frame()` method. """ - def __init__(self, initial=None): + def __init__( + self, initial: Optional[dict[tuple[int, int], int]] = None + ) -> None: """ Creates a new DeviceInstanceTypeMapper object, optionally with preloaded mappings as defined by `initial`. :param initial: A dict of data to preload into the mapping """ - self._mapping: dict[(int, int), int] = {} + self._mapping: dict[tuple[int, int], int] = {} if initial: self._mapping = initial @property - def mapping(self) -> dict[(int, int), int]: + def mapping(self) -> dict[tuple[int, int], int]: return self._mapping def autodiscover( @@ -89,7 +91,7 @@ def autodiscover( addresses: int | tuple[int, int] | Iterable[int] = (0, 63), quiescent_settle: float = 0.2, status_attempts: int = 3, - ) -> Generator[Command, Response, None]: + ) -> Generator[Command, Optional[Response], None]: """ A generator sequence to scan a DALI bus for control device instances, and query their types. This information is stored within this diff --git a/dali/device/occupancy.py b/dali/device/occupancy.py index 8c70b809..45057f35 100644 --- a/dali/device/occupancy.py +++ b/dali/device/occupancy.py @@ -80,7 +80,7 @@ def from_event_data(cls, event_data: int): return OccupancyEvent @property - def event_data(self): + def event_data(self) -> EventData: return self._extra_data def _set_event_data( diff --git a/dali/device/py.typed b/dali/device/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/dali/driver/py.typed b/dali/driver/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/dali/exceptions.py b/dali/exceptions.py index fd06e535..86ca0670 100644 --- a/dali/exceptions.py +++ b/dali/exceptions.py @@ -1,3 +1,5 @@ +from __future__ import annotations + ############################################################################### # general ############################################################################### @@ -50,7 +52,7 @@ class DALISequenceError(DALIError): class ProgramShortAddressFailure(DALIError): """A device did not accept programming of its short address.""" - def __init__(self, address): + def __init__(self, address: int) -> None: self.address = address @@ -125,7 +127,7 @@ class TransmissionError(DriverError): ``error_code`` is the interface-specific reason for the rejection. """ - def __init__(self, error_code): + def __init__(self, error_code: int) -> None: self.error_code = error_code super().__init__( f"Frame transmission rejected, error code {error_code}" diff --git a/dali/gear/general.py b/dali/gear/general.py index 99a728c3..d5467b93 100644 --- a/dali/gear/general.py +++ b/dali/gear/general.py @@ -7,6 +7,8 @@ or DTR0, the abbreviation has been kept in capitals. """ +from __future__ import annotations + from dali import command from dali import address from dali import frame @@ -61,7 +63,7 @@ class _StandardCommand(_GearCommand): _cmdval = None _hasparam = False - def __init__(self, destination, *args): + def __init__(self, destination: address.Address | int, *args: int) -> None: if self._cmdval is None: raise NotImplementedError @@ -155,7 +157,7 @@ class DAPC(_GearCommand): be respected. """ - def __init__(self, destination, power): + def __init__(self, destination: address.Address | int, power: int | str) -> None: if power == "OFF": power = 0 @@ -1003,7 +1005,7 @@ class _SpecialCommand(_GearCommand): _hasparam = False _cmdval = None - def __init__(self, *args): + def __init__(self, *args: int) -> None: if self._hasparam: if len(args) != 1: raise TypeError( diff --git a/dali/gear/py.typed b/dali/gear/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/dali/gear/sequences.py b/dali/gear/sequences.py index b25c46d3..02ecd2f6 100644 --- a/dali/gear/sequences.py +++ b/dali/gear/sequences.py @@ -34,7 +34,7 @@ def SetDT8ColourValueTc( - address: GearAddress, + address: GearAddress | int, tc_mired: int, ) -> Generator[command.Command, Optional[command.Response], None]: """ @@ -265,7 +265,7 @@ def QueryEmergencyInformation( def EmergencyCommand( - address: GearShort, + address: GearShort | int, command_class: type[command.Command], ) -> Generator[command.Command, Optional[command.Response], Optional[command.Response]]: """Issue a single DT1 (IEC 62386-202) application-extended command. diff --git a/dali/memory/location.py b/dali/memory/location.py index d990009e..41fe98a6 100644 --- a/dali/memory/location.py +++ b/dali/memory/location.py @@ -1,9 +1,11 @@ from __future__ import annotations from collections import namedtuple +from collections.abc import Generator from enum import Enum, auto +from typing import Any, Optional -from dali import device, gear +from dali import command, device, gear from dali.address import Address, DeviceAddress, DeviceShort, GearAddress, GearShort from dali.exceptions import ( LatchingNotSupported, @@ -165,7 +167,11 @@ def last_address(self, addr): la = yield from self.LastAddress.read(addr) return la - def read_all(self, addr: Address, use_latch: bool = True): + def read_all( + self, addr: Address, use_latch: bool = True + ) -> Generator[ + command.Command, Optional[command.Response], dict[type[MemoryValue], Any] + ]: """Read all available memory values from this memory bank. If the memory bank has a latch, the latch is set during the diff --git a/dali/memory/py.typed b/dali/memory/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/dali/py.typed b/dali/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/dali/sequences.py b/dali/sequences.py index ff444bb0..5aa3080c 100644 --- a/dali/sequences.py +++ b/dali/sequences.py @@ -18,10 +18,16 @@ # Sequences may raise exceptions, which the driver should pass to the # caller. +from __future__ import annotations + +from collections.abc import Iterable +from typing import Generator, Optional + +from dali import command from dali.exceptions import DALISequenceError, ProgramShortAddressFailure from dali.gear.general import * -from dali.address import Broadcast, Short +from dali.address import Broadcast, GearAddress, Short class sleep: @@ -30,7 +36,7 @@ class sleep: Yielded during a sequence to request that the caller wait for at least the specified length of time in seconds """ - def __init__(self, delay): + def __init__(self, delay: float) -> None: self.delay = delay @@ -42,7 +48,12 @@ class progress: both. The amount of progress is just an indication and there is no guarantee that it will not decrease as well as increase. """ - def __init__(self, message=None, completed=None, size=None): + def __init__( + self, + message: str | None = None, + completed: int | None = None, + size: int | None = None, + ) -> None: self.message = message self.completed = completed self.size = size @@ -54,7 +65,9 @@ def __str__(self): return f"Progress: {self.completed}/{self.size}" -def QueryDeviceTypes(addr): +def QueryDeviceTypes( + addr: GearAddress, +) -> Generator[command.Command, Optional[command.Response], list[int]]: """Obtain a list of part 2xx device types supported by control gear """ r = yield QueryDeviceType(addr) @@ -147,8 +160,11 @@ def _find_next(low, high): return (yield from _find_next(midpoint + 1, high)) -def Commissioning(available_addresses=None, readdress=False, - dry_run=False): +def Commissioning( + available_addresses: Iterable[int] | None = None, + readdress: bool = False, + dry_run: bool = False, +) -> Generator[command.Command, Optional[command.Response], None]: """Assign short addresses to control gear If available_addresses is passed, only the specified addresses diff --git a/setup.cfg b/setup.cfg index 44f1604a..44231c15 100644 --- a/setup.cfg +++ b/setup.cfg @@ -21,6 +21,7 @@ classifiers = [options] python_requires = >=3.10 +include_package_data = True packages = dali dali.device