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 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/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/__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.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/general_sensor.py b/dali/device/general_sensor.py new file mode 100644 index 00000000..064503f0 --- /dev/null +++ b/dali/device/general_sensor.py @@ -0,0 +1,233 @@ +"""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. + + 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 + 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_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 A.1.""" + + 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 + DEGREE_ANGLE = 12 + RADIAN = 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 + VOLT_PER_METRE = 28 + AMPERE_HOUR = 29 + WATT_HOUR = 30 + LUMEN = 31 + LUX = 32 + CANDELA = 33 + CANDELA_PER_SQUARE_METRE = 34 + 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 + # 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): + """Standard quantity-name values from Part 306 Annex A, Table A.2.""" + + MANUFACTURER_DEFINED = 0 + TIME = 1 + FREQUENCY = 2 + 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 + POWER_APPARENT = 16 + POWER_REACTIVE = 17 + ENERGY = 18 + POWER_FACTOR = 19 + SOUND_PRESSURE_LEVEL = 20 + 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 + 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): + """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/device/helpers.py b/dali/device/helpers.py index 4c250a7b..cdc3a8e8 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: @@ -69,24 +69,29 @@ 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( - self, addresses: int | tuple[int, int] | Iterable[int] = (0, 63) - ) -> Generator[Command, Response, None]: + self, + addresses: int | tuple[int, int] | Iterable[int] = (0, 63), + quiescent_settle: float = 0.2, + status_attempts: int = 3, + ) -> 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 @@ -99,6 +104,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 +121,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 +136,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): @@ -140,7 +164,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/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/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/driver/py.typed b/dali/driver/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/dali/driver/serial.py b/dali/driver/serial.py index 24ba70b2..ee21d1ca 100644 --- a/dali/driver/serial.py +++ b/dali/driver/serial.py @@ -29,16 +29,27 @@ 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 from dali.driver import trace_logging # noqa: F401 from dali.device.helpers import DeviceInstanceTypeMapper +from dali.exceptions import TransmissionError _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): """ @@ -301,6 +312,14 @@ 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 + # 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}) class LubaCmd(Enum): """ @@ -315,6 +334,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 @@ -323,6 +344,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 @@ -342,6 +378,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 @@ -372,6 +428,7 @@ class LubaMsgTxConf(NamedTuple): tx_id: int message: Optional[command.Command] = None + error_code: Optional[int] = None def __init__(self) -> None: super().__init__() @@ -392,6 +449,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() @@ -443,20 +502,28 @@ 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 + + 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: @@ -525,6 +592,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( @@ -538,7 +607,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" ) @@ -580,6 +649,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 +747,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 +893,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 @@ -807,7 +960,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 @@ -904,6 +1063,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( @@ -959,14 +1125,74 @@ 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) + 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 @@ -997,7 +1223,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: @@ -1011,12 +1237,15 @@ 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}'") - self._transport: Optional[serial_asyncio.SerialTransport] = None + # 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 async def connect(self, *, scan_dev_inst: bool = False) -> None: @@ -1030,7 +1259,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, @@ -1045,12 +1274,33 @@ 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() - 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() + # 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: @@ -1061,6 +1311,31 @@ 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]: + """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]: @@ -1079,7 +1354,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( @@ -1274,34 +1552,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: @@ -1583,7 +1855,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: @@ -1603,7 +1875,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 +1893,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, @@ -1670,7 +1942,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/exceptions.py b/dali/exceptions.py index 91dbbc9a..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 @@ -119,6 +121,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: int) -> None: + 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 """ 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 e087e7de..02ecd2f6 100644 --- a/dali/gear/sequences.py +++ b/dali/gear/sequences.py @@ -3,22 +3,38 @@ """ from __future__ import annotations +from collections import namedtuple from typing import Generator, Optional from dali import command from dali.address import GearAddress, GearShort from dali.gear.colour import ( Activate, + QueryColourStatus, + QueryColourTypeFeatures, QueryColourValue, QueryColourValueDTR, 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 def SetDT8ColourValueTc( - address: GearAddress, + address: GearAddress | int, tc_mired: int, ) -> Generator[command.Command, Optional[command.Response], None]: """ @@ -42,6 +58,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, @@ -112,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 | 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. + + 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/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/dali/tests/fakes.py b/dali/tests/fakes.py index 3885cccf..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): @@ -488,15 +538,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 +561,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 +666,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 910a8e14..9f203317 100644 --- a/dali/tests/test_device_sequences.py +++ b/dali/tests/test_device_sequences.py @@ -13,12 +13,14 @@ QueryEventSchemeResponse, QueryInputValue, QueryInputValueLatch, + QueryNumberOfInstances, QueryResolution, SetEventFilter, ) 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, @@ -110,6 +112,83 @@ 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_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) @@ -363,3 +442,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 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_general_sensor.py b/dali/tests/test_general_sensor.py new file mode 100644 index 00000000..b33a28df --- /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 == 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 == 50 + assert UnitOfMeasurement.PARTS_PER_MILLION == 53 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) diff --git a/dali/tests/test_serial_driver.py b/dali/tests/test_serial_driver.py new file mode 100644 index 00000000..ded3879f --- /dev/null +++ b/dali/tests/test_serial_driver.py @@ -0,0 +1,117 @@ +import asyncio +import logging +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) + + +@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) + + +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() diff --git a/setup.cfg b/setup.cfg index e2d4b4eb..44231c15 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 @@ -20,7 +20,8 @@ classifiers = Programming Language :: Python :: 3 [options] -python_requires = >=3.7 +python_requires = >=3.10 +include_package_data = True packages = dali dali.device @@ -33,11 +34,11 @@ packages = driver-unipi = pyusb pymodbus -driver-serial = pyserial-asyncio +driver-serial = serialx test = pytest pytest-asyncio - pyserial-asyncio + serialx [flake8] exclude = */driver/