diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 95f4239a..da88beac 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -20,6 +20,7 @@ jobs: run: | python -m pip install --upgrade pip pip install flake8 + pip install mypy pip install -e ".[test]" - name: Lint with flake8 run: | @@ -27,3 +28,6 @@ jobs: - name: Test with pytest run: | pytest dali/tests + - name: Typecheck with mypy + run: | + mypy --strict dali/gear/ dali/device/ diff --git a/dali/address.py b/dali/address.py index c064b161..217137e1 100644 --- a/dali/address.py +++ b/dali/address.py @@ -20,6 +20,10 @@ 7.2.2. Decoding of event messages is currently not implemented. """ +from __future__ import annotations + +from typing import Optional +from dali.frame import Frame from dali.exceptions import IncompatibleFrame @@ -31,39 +35,35 @@ ############################################################################### -class AddressTracker(type): - """Metaclass keeping track of all the types of Address we understand.""" - - def __init__(cls, name, bases, attrs): - if not hasattr(cls, "_addrtypes"): - cls._addrtypes = [] - else: - cls._addrtypes.append(cls) - - -class Address(metaclass=AddressTracker): +class Address: """An address for one or more ballasts.""" - required_frame_size = None + required_frame_size: Optional[int] = None + _addrtypes: list[type[Address]] = [] + + def __init_subclass__(cls) -> None: + super().__init_subclass__() + cls._addrtypes.append(cls) @classmethod - def from_frame(cls, f): + def from_frame(cls, f: Frame) -> Optional[Address]: """Return an Address object If the frame has a destination address, return a suitable Address object; otherwise return None. """ if cls != Address: - return + return None for at in cls._addrtypes: r = at.from_frame(f) if r: return r + return None - def add_to_frame(self, f): + def add_to_frame(self, f: Frame) -> None: raise IncompatibleFrame("Cannot add unknown address to any frame") - def __str__(self): + def __str__(self) -> str: return "" @@ -83,21 +83,22 @@ class GearBroadcast(GearAddress): """All control gear connected to the network""" @classmethod - def from_frame(cls, f): + def from_frame(cls, f: Frame) -> Optional[GearBroadcast]: if len(f) == 16: if f[15:9] == 0x7F: return cls() + return None - def add_to_frame(self, f): + def add_to_frame(self, f: Frame) -> None: if len(f) == 16: f[15:9] = 0x7F else: raise _bad_frame_length - def __eq__(self, other): + def __eq__(self, other: object) -> bool: return isinstance(other, GearBroadcast) - def __str__(self): + def __str__(self) -> str: return "" @@ -110,21 +111,22 @@ class DeviceBroadcast(DeviceAddress): """All control devices connected to the network""" @classmethod - def from_frame(cls, f): + def from_frame(cls, f: Frame) -> Optional[DeviceBroadcast]: if len(f) == 24: if f[16] and f[23:17] == 0x7F: return cls() + return None - def add_to_frame(self, f): + def add_to_frame(self, f: Frame) -> None: if len(f) == 24: f[23:17] = 0x7F else: raise _bad_frame_length - def __eq__(self, other): + def __eq__(self, other: object) -> bool: return isinstance(other, DeviceBroadcast) - def __str__(self): + def __str__(self) -> str: return "" @@ -135,21 +137,22 @@ class GearBroadcastUnaddressed(GearAddress): """ @classmethod - def from_frame(cls, f): + def from_frame(cls, f: Frame) -> Optional[GearBroadcastUnaddressed]: if len(f) == 16: if f[15:9] == 0x7E: return cls() + return None - def add_to_frame(self, f): + def add_to_frame(self, f: Frame) -> None: if len(f) == 16: f[15:9] = 0x7E else: raise _bad_frame_length - def __eq__(self, other): + def __eq__(self, other: object) -> bool: return isinstance(other, GearBroadcastUnaddressed) - def __str__(self): + def __str__(self) -> str: return "" @@ -166,28 +169,29 @@ class DeviceBroadcastUnaddressed(DeviceAddress): """ @classmethod - def from_frame(cls, f): + def from_frame(cls, f: Frame) -> Optional[DeviceBroadcastUnaddressed]: if len(f) == 24: if f[16] and f[23:17] == 0x7E: return cls() + return None - def add_to_frame(self, f): + def add_to_frame(self, f: Frame) -> None: if len(f) == 24: f[23:17] = 0x7E else: raise _bad_frame_length - def __eq__(self, other): + def __eq__(self, other: object) -> bool: return isinstance(other, DeviceBroadcastUnaddressed) - def __str__(self): + def __str__(self) -> str: return "" class GearGroup(GearAddress): """All control gear that are members of the specified group""" - def __init__(self, group: int): + def __init__(self, group: int) -> None: if not isinstance(group, int): raise ValueError("group must be an integer") if group < 0 or group > 15: @@ -195,22 +199,23 @@ def __init__(self, group: int): self.group = group @classmethod - def from_frame(cls, f): + def from_frame(cls, f: Frame) -> Optional[GearGroup]: if len(f) == 16: if f[15:13] == 0x4: return cls(f[12:9]) + return None - def add_to_frame(self, f): + def add_to_frame(self, f: Frame) -> None: if len(f) == 16: f[15:13] = 0x4 f[12:9] = self.group else: raise _bad_frame_length - def __eq__(self, other): + def __eq__(self, other: object) -> bool: return isinstance(other, GearGroup) and other.group == self.group - def __str__(self): + def __str__(self) -> str: return f"" @@ -222,7 +227,7 @@ def __str__(self): class DeviceGroup(DeviceAddress): """All control devices that are members of the specified group""" - def __init__(self, group: int): + def __init__(self, group: int) -> None: if not isinstance(group, int): raise ValueError("group must be an integer") if group < 0 or group > 31: @@ -230,22 +235,23 @@ def __init__(self, group: int): self.group = group @classmethod - def from_frame(cls, f): + def from_frame(cls, f: Frame) -> Optional[DeviceGroup]: if len(f) == 24: if f[16] and f[23:22] == 0x2: return cls(f[21:17]) + return None - def add_to_frame(self, f): + def add_to_frame(self, f: Frame) -> None: if len(f) == 24: f[23:22] = 0x2 f[21:17] = self.group else: raise _bad_frame_length - def __eq__(self, other): + def __eq__(self, other: object) -> bool: return isinstance(other, DeviceGroup) and other.group == self.group - def __str__(self): + def __str__(self) -> str: return f"" @@ -261,7 +267,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: @@ -269,22 +275,23 @@ def __init__(self, address): self.address = address @classmethod - def from_frame(cls, f): + def from_frame(cls, f: Frame) -> Optional[GearAddress]: if len(f) == 16: if not f[15]: return cls(f[14:9]) + return None - def add_to_frame(self, f): + def add_to_frame(self, f: Frame) -> None: if len(f) == 16: f[15] = False f[14:9] = self.address else: raise _bad_frame_length - def __eq__(self, other): + def __eq__(self, other: object) -> bool: return isinstance(other, GearShort) and other.address == self.address - def __str__(self): + def __str__(self) -> str: return f"
" @@ -305,7 +312,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: @@ -313,22 +320,23 @@ def __init__(self, address: int): self.address = address @classmethod - def from_frame(cls, f): + def from_frame(cls, f: Frame) -> Optional[DeviceShort]: if len(f) == 24: if f[16] and not f[23]: return cls(f[22:17]) + return None - def add_to_frame(self, f): + def add_to_frame(self, f: Frame) -> None: if len(f) == 24: f[23] = False f[22:17] = self.address else: raise _bad_frame_length - def __eq__(self, other): + def __eq__(self, other: object) -> bool: return isinstance(other, DeviceShort) and other.address == self.address - def __str__(self): + def __str__(self) -> str: return f"
" @@ -341,72 +349,79 @@ def __str__(self): class Instance: - def __init__(self): + _value = -1 + + def __init__(self) -> None: raise NotImplementedError @property - def value(self): - if hasattr(self, "_value"): - return self._value + def value(self) -> int: + if self._value == -1: + raise NotImplementedError + return self._value - def add_to_frame(self, f): + def add_to_frame(self, f: Frame) -> None: raise NotImplementedError - def __eq__(self, other): + def __eq__(self, other: object) -> bool: if isinstance(other, self.__class__): if hasattr(self, "_value") and hasattr(other, "_value"): if self._value == other._value: return True - return False class ReservedInstance(Instance): """A reserved instance byte.""" - def __init__(self, value): + def __init__(self, value: int) -> None: self._value = value - def add_to_frame(self, f): + def add_to_frame(self, f: Frame) -> None: if len(f) != 24: raise _bad_frame_length f[15:8] = self._value - def __str__(self): + def __str__(self) -> str: return "ReservedInstance({:02x})".format(self._value) class _AddressedInstance(Instance): - _flags = None + _flags = -1 - 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: raise ValueError("value must be in the range 0..31") self._value = value - def add_to_frame(self, f): + def add_to_frame(self, f: Frame) -> None: + if self._flags == -1: + raise NotImplementedError if len(f) != 24: raise _bad_frame_length f[15:8] = self._flags | self._value - def __str__(self): + def __str__(self) -> str: return "{}({})".format(self.__class__.__name__, self._value) class _UnaddressedInstance(Instance): - _val = None + _val = -1 - def __init__(self): + def __init__(self) -> None: + # Do not call superclass __init__()! pass - def add_to_frame(self, f): + def add_to_frame(self, f: Frame) -> None: + if self._val == -1: + raise NotImplementedError if len(f) != 24: raise _bad_frame_length f[15:8] = self._val - def __str__(self): + def __str__(self) -> str: return "{}()".format(self.__class__.__name__) @@ -450,9 +465,9 @@ class Device(_UnaddressedInstance): _val = 0xFE -def instance_from_frame(f): +def instance_from_frame(f: Frame) -> Optional[Instance]: if len(f) != 24: - return + return None flags = f[15:13] p = f[12:8] b = f[15:8] diff --git a/dali/command.py b/dali/command.py index e4a49467..23ed6068 100644 --- a/dali/command.py +++ b/dali/command.py @@ -1,34 +1,23 @@ """Declaration of base types for dali commands and their responses.""" +from __future__ import annotations + +from typing import Optional, Protocol, Literal from enum import IntEnum -from dali import address -from dali import frame +from dali import frame, address from dali.exceptions import MissingResponse from dali.exceptions import ResponseError -class _CommandTracker(type): - """Metaclass keeping track of all the types of Command we understand. - - Commands that have names starting with '_' are treated as abstract - base classes that will never be instantiated because they do not - correspond to a DALI frame. - """ - - def __init__(cls, name, bases, attrs): - if not hasattr(cls, '_commands'): - cls._commands = [] - else: - if cls.__name__[0] != '_': - cls._commands.append(cls) - if not hasattr(cls, '_supported_devicetypes'): - cls._supported_devicetypes = set() - else: - if cls.devicetype != 0: - cls._supported_devicetypes.add(cls.devicetype) - for c in bases: - if issubclass(c, Command): - c._register_subclass(cls) +# Device Instance Type Mapper +class DITM(Protocol): + def get_type( + self, + *, + short_address: address.DeviceShort | int, + instance_number: address.InstanceNumber | int, + ) -> Optional[int]: + ... class Response: @@ -46,27 +35,50 @@ class Response: _expected = False _error_acceptable = False - def __init__(self, val): + def __init__(self, val: Optional[frame.BackwardFrame]) -> 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) -> Optional[frame.BackwardFrame]: return self._value @property - def value(self): + def missing(self) -> bool: + return self._value is None and self._expected + + @property + def error(self) -> bool: + if self._value: + return self._value.error and not self._error_acceptable + return False + + def raise_for_status(self) -> None: + if self.missing: + raise MissingResponse() + if self.error: + raise ResponseError() + + @property + def valid(self) -> bool: + return not (self.missing or self.error) + + # The "value" property has too many possible types to be useful. + # It will be deprecated in favour of type-specific properties on + # subclasses. + @property + def value(self) -> Optional[frame.BackwardFrame | int | str | bool | IntEnum]: if self._value is None and self._expected: raise MissingResponse() if self._value and self._value.error and not self._error_acceptable: raise ResponseError() return self._value - def __str__(self): + def __str__(self) -> str: try: return "{}".format(self.value) - except MissingResponse or ResponseError as e: + except (MissingResponse, ResponseError) as e: return "{}".format(e) @@ -74,18 +86,31 @@ class NumericResponse(Response): _expected = True @property - def value(self): + def value(self) -> int | str: if self._value is None: return "(missing)" if self._value and self._value.error: return "(framing error)" return self._value.as_integer + @property + def as_integer(self) -> int: + self.raise_for_status() + assert self._value + return self._value.as_integer + class NumericResponseMask(NumericResponse): @property - def value(self): - v = super(NumericResponseMask, self).value + def value(self) -> int | str: + v = super().value + if v == 255: + return "MASK" + return v + + @property + def as_maskinteger(self) -> int | Literal["MASK"]: + v = self.as_integer if v == 255: return "MASK" return v @@ -95,14 +120,19 @@ class YesNoResponse(Response): _error_acceptable = True @property - def value(self): + def value(self) -> bool: + return self._value is not None + + @property + def as_boolean(self) -> bool: return self._value is not None class BitmapResponseBitDict(type): """Metaclass adding dict of status bits.""" - def __init__(cls, name, bases, attrs): + def __init__(cls: type, name: str, bases: tuple[type], + attrs: dict[str, object]) -> None: if hasattr(cls, "bits"): bd = {} bit = 0 @@ -120,14 +150,16 @@ class BitmapResponse(Response, metaclass=BitmapResponseBitDict): Bits are listed in subclasses with the least-sigificant bit first. """ _expected = True - bits = [] + _bit_properties: dict[str, int] + bits: list[Optional[str]] = [] @property - def status(self): + def status(self) -> list[str]: if self._value is None: raise MissingResponse() if self._value.error: - return ["response received with framing error"] + raise ResponseError() + # return ["response received with framing error"] v = self._value[7:0] l = [] for b in self.bits: @@ -136,26 +168,19 @@ def status(self): v = (v >> 1) return l - @property - def error(self): - if self._value is None: - return False - return self._value.error - - def __getattr__(self, name): + def __getattr__(self, name: str) -> Optional[bool]: if name in self._bit_properties: if self._value is None: - return + return None if self._value.error: - return - return self._value[self._bit_properties[name]] + return None + return bool(self._value[self._bit_properties[name]]) raise AttributeError - def __str__(self): + def __str__(self) -> str: try: return ",".join(self.status) - # XXX: be more explicit which exception to catch - except Exception as e: + except (MissingResponse, ResponseError) as e: return "{}".format(e) @@ -164,17 +189,20 @@ class EnumResponse(Response): A response that consists of a number from a pre-defined enumerator """ - enumerator: IntEnum = None + enumerator: Optional[type[IntEnum]] = None @property - def value(self): - _value = super().value - if _value: - return self.enumerator(_value.as_integer) # noqa - return _value + def value(self) -> Optional[frame.BackwardFrame | int | str | IntEnum]: + if self._value is None and self._expected: + raise MissingResponse() + if self._value and self._value.error and not self._error_acceptable: + raise ResponseError() + if self._value and self.enumerator: + return self.enumerator(self._value.as_integer) + return self._value -class Command(metaclass=_CommandTracker): +class Command: """A command frame. Subclasses must provide a class method "from_frame" which, when @@ -197,7 +225,7 @@ class Command(metaclass=_CommandTracker): uses_dtr0 = False uses_dtr1 = False uses_dtr2 = False - response = None + response: Optional[type[Response]] = None sendtwice = False # 16-bit frames may be interpreted differently if they are @@ -206,22 +234,46 @@ class Command(metaclass=_CommandTracker): # foo. This parameter is ignored for all other frame lengths. devicetype = 0 - def __init__(self, f): + _commands: list[type[Command]] = [] + _supported_devicetypes: set[int] = set() + _framesizes: dict[int, list[type[Command]]] = {} + + def __init_subclass__(cls) -> None: + if cls.__name__[0] != '_': + cls._commands.append(cls) + if cls.devicetype != 0: + cls._supported_devicetypes.add(cls.devicetype) + subs = cls._framesizes.setdefault(cls._framesize, list()) + subs.append(cls) + + # XXX we have a _register_subclass() method which duplicates what + # we are doing here in __init_subclass__() — we had it before + # __init_subclass__() existed! + # + # For now, keep it working. All uses of this should be replaced + # by __init_subclass__() methods that extend this method. + for base in cls.__bases__: + if issubclass(base, Command): + base._register_subclass(cls) + + def __init__(self, f: frame.ForwardFrame) -> None: if not isinstance(f, frame.ForwardFrame): raise TypeError( f"{self.__class__.__name__} expected a ForwardFrame, not a {type(f)}" ) self._data = f - _framesizes = {} - + # This is deprecated and will be removed. For now it is overridden in + # subclasses. @classmethod - def _register_subclass(cls, subclass): - subs = cls._framesizes.setdefault(subclass._framesize, list()) - subs.append(subclass) + def _register_subclass(cls, subclass: type[Command]) -> None: + pass @classmethod - def from_frame(cls, f, devicetype=0, dev_inst_map=None): + def from_frame( + cls, f: frame.ForwardFrame, devicetype: int = 0, + dev_inst_map: Optional[DITM] = None + ) -> Optional[Command]: """Return a Command instance corresponding to the supplied frame. If the device type the command is intended for is known @@ -243,7 +295,8 @@ def from_frame(cls, f, devicetype=0, dev_inst_map=None): subs = cls._framesizes.get(len(f), []) for c in subs: - r = c.from_frame(f, devicetype=devicetype, dev_inst_map=dev_inst_map) + r = c.from_frame(f, devicetype=devicetype, + dev_inst_map=dev_inst_map) if r: return r @@ -251,34 +304,16 @@ def from_frame(cls, f, devicetype=0, dev_inst_map=None): return cls(f) @property - def frame(self): + def frame(self) -> frame.ForwardFrame: """The forward frame to be transmitted for this command.""" return self._data @property - def is_query(self): + def is_query(self) -> bool: """Does this command return a result?""" return self.response is not None - @staticmethod - def _check_destination(destination): - """Check that a valid destination has been specified. - - destination can be a dali.bus.Device object with - address_obj attribute, a dali.address.Address object with - add_to_frame method, or an integer which will be wrapped in a - dali.address.Address object. - """ - if hasattr(destination, 'address_obj'): - destination = destination.address_obj - if isinstance(destination, int): - destination = address.Short(destination) - if hasattr(destination, 'add_to_frame'): - return destination - raise ValueError('destination must be an integer, dali.bus.Device ' - 'object or dali.address.Address object') - - def __str__(self): + def __str__(self) -> str: joined = ":".join( "{:02x}".format(c) for c in self._data.as_byte_sequence) return "({0}){1}".format(type(self), joined) diff --git a/dali/device/general.py b/dali/device/general.py index 24ac2f54..962bac10 100644 --- a/dali/device/general.py +++ b/dali/device/general.py @@ -50,41 +50,58 @@ from __future__ import annotations from enum import IntEnum, IntFlag -from typing import Any, Optional, TYPE_CHECKING, Type +from typing import Any, Optional, Type from dali import address, command, frame -if TYPE_CHECKING: - from dali.device.helpers import DeviceInstanceTypeMapper - class _DeviceCommand(command.Command): """A command addressed to a control device.""" _framesize = 24 - _devicecommands = [] + _devicecommands: list[type[_DeviceCommand]] = [] @classmethod - def _register_subclass(cls, subclass): + def _register_subclass(cls, subclass: type[command.Command]) -> None: + assert issubclass(subclass, _DeviceCommand) cls._devicecommands.append(subclass) @classmethod - def from_frame(cls, f, devicetype=0, dev_inst_map=None): + def from_frame(cls, f: frame.ForwardFrame, devicetype: int = 0, + dev_inst_map: Optional[command.DITM] = None + ) -> Optional[_DeviceCommand]: # In 24-bit frames, bit 16 is 1 for "commands" (as opposed to "events") if not f[16]: - return + return None for dc in cls._devicecommands: r = dc.from_frame(f, dev_inst_map=dev_inst_map) if r: return r return UnknownDeviceCommand(f) + @staticmethod + def _check_destination( + destination: int | address.DeviceAddress) -> address.DeviceAddress: + """Check that a valid destination has been specified. + + destination can be a dali.address.Address object with + add_to_frame method, or an integer which will be wrapped in a + dali.address.Address object. + """ + if isinstance(destination, int): + destination = address.DeviceShort(destination) + if isinstance(destination, address.DeviceAddress): + return destination + raise ValueError('destination must be an integer or ' + 'dali.address.DeviceAddress object') + class UnknownDeviceCommand(_DeviceCommand): """An unknown command addressed to a control device. """ @classmethod - def from_frame(cls, f, devicetype=0, dev_inst_map=None): - return + def from_frame(cls, f: frame.ForwardFrame, devicetype: int = 0, + dev_inst_map: Optional[command.DITM] = None) -> None: + return None class InstanceEventFilter(IntFlag): @@ -126,10 +143,10 @@ class _StandardDeviceCommand(_DeviceCommand): addressed to the device as a whole, not to any particular instance. """ - _opcode = None + _opcode = -1 - def __init__(self, device): - if self._opcode is None: + def __init__(self, device: int | address.DeviceAddress) -> None: + if self._opcode == -1: raise NotImplementedError self.destination = self._check_destination(device) @@ -139,21 +156,25 @@ def __init__(self, device): super().__init__(f) - _opcodes = {} + _opcodes: dict[int, type[_StandardDeviceCommand]] = {} @classmethod - def _register_subclass(cls, subclass): + def _register_subclass(cls, subclass: type[command.Command]) -> None: + assert issubclass(subclass, _StandardDeviceCommand) cls._opcodes[subclass._opcode] = subclass @classmethod - def from_frame(cls, f, devicetype=0, dev_inst_map=None): + def from_frame(cls, f: frame.ForwardFrame, devicetype: int = 0, + dev_inst_map: Optional[command.DITM] = None + ) -> Optional[_DeviceCommand]: if f[16:8] != 0x1FE: - return + return None addr = address.from_frame(f) if addr is None: - return + return None + assert isinstance(addr, address.DeviceAddress) cc = cls._opcodes.get(f[7:0]) if not cc: @@ -161,7 +182,7 @@ def from_frame(cls, f, devicetype=0, dev_inst_map=None): return cc(addr) - def __str__(self): + def __str__(self) -> str: return "%s(%s)" % (self.__class__.__name__, self.destination) @@ -512,10 +533,11 @@ class QueryResetState(_StandardDeviceCommand): class _StandardInstanceCommand(_DeviceCommand): """A standard command addressed to a control device instance.""" - _opcode = None + _opcode = -1 - def __init__(self, device, instance): - if self._opcode is None: + def __init__(self, device: int | address.DeviceAddress, + instance: address.Instance) -> None: + if self._opcode == -1: raise NotImplementedError self.destination = self._check_destination(device) @@ -532,19 +554,25 @@ def __init__(self, device, instance): _opcodes: dict[int, Type[_StandardInstanceCommand]] = dict() @classmethod - def _register_subclass(cls, subclass: Type[_StandardInstanceCommand]): + def _register_subclass(cls, subclass: type[command.Command]) -> None: + assert issubclass(subclass, _StandardInstanceCommand) cls._opcodes[subclass._opcode] = subclass @classmethod - def from_frame(cls, f, devicetype=0, dev_inst_map=None): + def from_frame(cls, f: frame.ForwardFrame, devicetype: int = 0, + dev_inst_map: Optional[command.DITM] = None + ) -> Optional[_DeviceCommand]: # In 24-bit frames, bit 16 is 1 for "commands" (as opposed to "events") if not f[16]: - return + return None + addr = address.from_frame(f) instance = address.instance_from_frame(f) if addr is None or instance is None: - return + return None + + assert isinstance(addr, address.DeviceAddress) cc = cls._opcodes.get(f[7:0]) if not cc: @@ -552,7 +580,7 @@ def from_frame(cls, f, devicetype=0, dev_inst_map=None): return cc(addr, instance) - def __str__(self): + def __str__(self) -> str: return "{}({}, {})".format( self.__class__.__name__, self.destination, self.instance) @@ -761,30 +789,34 @@ class QueryEventFilterSixteenToTwentyThree(_StandardInstanceCommand): class _SpecialDeviceCommand(_DeviceCommand): - _addr = None - _instance = None + _addr = -1 + _instance = -1 _opcode = 0x00 - def __init__(self): - if self._addr is None or self._instance is None: + def __init__(self) -> None: + if self._addr == -1 or self._instance == -1: raise NotImplementedError super().__init__( frame.ForwardFrame(24, (self._addr, self._instance, self._opcode)) ) @classmethod - def from_frame(cls, f, devicetype=0, dev_inst_map=None): + def from_frame(cls, f: frame.ForwardFrame, devicetype: int = 0, + dev_inst_map: Optional[command.DITM] = None + ) -> Optional[_SpecialDeviceCommand]: if cls == _SpecialDeviceCommand: - return - if f[23:16] == cls._addr and f[15:8] == cls._instance and f[7:0] == 0x00: + return None + if f[23:16] == cls._addr and f[15:8] == cls._instance \ + and f[7:0] == 0x00: return cls() + return None - def __str__(self): + def __str__(self) -> str: return "{}()".format(self.__class__.__name__) 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: @@ -793,22 +825,25 @@ def __init__(self, param): super().__init__() @classmethod - def from_frame(cls, f, devicetype=0, dev_inst_map=None): + def from_frame(cls, f: frame.ForwardFrame, devicetype: int = 0, + dev_inst_map: Optional[command.DITM] = None + ) -> Optional[_SpecialDeviceCommandOneParam]: if cls == _SpecialDeviceCommandOneParam: - return + return None if f[23:16] == cls._addr and f[15:8] == cls._instance: return cls(f[7:0]) + return None @property def param(self) -> int: return self._opcode - def __str__(self): + def __str__(self) -> str: return "{}({:02x})".format(self.__class__.__name__, self._opcode) class _SpecialDeviceCommandTwoParam(_SpecialDeviceCommand): - def __init__(self, a, b): + def __init__(self, a: int, b: int) -> None: if not isinstance(a, int) or not isinstance(b, int): raise ValueError("parameters must be integers") if a < 0 or a > 255 or b < 0 or b > 255: @@ -818,11 +853,14 @@ def __init__(self, a, b): super().__init__() @classmethod - def from_frame(cls, f, devicetype=0, dev_inst_map=None): + def from_frame(cls, f: frame.ForwardFrame, devicetype: int = 0, + dev_inst_map: Optional[command.DITM] = None + ) -> Optional[_SpecialDeviceCommandTwoParam]: if cls == _SpecialDeviceCommandTwoParam: - return + return None if f[23:16] == cls._addr: return cls(f[15:8], f[7:0]) + return None @property def param_1(self) -> int: @@ -832,7 +870,7 @@ def param_1(self) -> int: def param_2(self) -> int: return self._opcode - def __str__(self): + def __str__(self) -> str: return "{}({:02x}, {:02x})".format( self.__class__.__name__, self._instance, self._opcode) @@ -971,16 +1009,16 @@ class _Event(command.Command): # The metaclass will call '_register_subclass()', which then adds to this # dict to maintain a mapping of 'instance type' codes to the Python class # which implements that type - _instance_types: dict[int, Type[_Event]] = {} + _instance_types: dict[int, type[_Event]] = {} # Identifying information, not all will be present for each message - _short_address = None + _short_address: Optional[address.DeviceShort] = None _instance_number = None _instance_group = None _device_group = None # Instance Type and Event Info will be overridden in subclasses - _instance_type = None - _event_info = None + _instance_type: Optional[int] = None + _event_info = -1 def __init__( self, @@ -990,12 +1028,12 @@ def __init__( instance_group: Optional[int] = None, device_group: Optional[int] = None, data: Any = None, - ): + ) -> None: if isinstance(self, (AmbiguousInstanceType, UnknownEvent)): # Start with an empty frame for these edge-case types, the rest will # be filled in later to make the frame survive a round-trip f = frame.ForwardFrame(24, 0) - elif self._event_info is None: + elif self._event_info == -1: raise NotImplementedError else: f = frame.ForwardFrame(24, self._event_info) @@ -1030,6 +1068,7 @@ def __init__( self._short_address = short_address else: self._short_address = address.DeviceShort(short_address) + assert self.short_address is not None self.short_address.add_to_frame(f) elif device_group is not None: @@ -1098,7 +1137,10 @@ def __init__( super().__init__(f) - def _set_event_data(self, set_data: Optional[int], set_frame: frame.Frame): + def _set_event_data( + self, + set_data: Any, + set_frame: frame.ForwardFrame) -> None: """ Overridden in subclasses which have data to include in the 10 bits of event information, by default does nothing @@ -1106,7 +1148,7 @@ def _set_event_data(self, set_data: Optional[int], set_frame: frame.Frame): return @property - def event_data(self): + def event_data(self) -> Any: """ Overridden in subclasses which have data, primarily used to format data in a suitable way for the __repr__ method @@ -1119,6 +1161,7 @@ def short_address(self) -> Optional[address.DeviceShort]: @property def instance_type(self) -> int: + assert self._instance_type is not None return self._instance_type @property @@ -1134,31 +1177,30 @@ def instance_number(self) -> Optional[int]: return self._instance_number @classmethod - def _register_subclass(cls, subclass): - if not issubclass(subclass, _Event): - raise RuntimeError( - "Somehow called _Event._register_subclass() " - "without a subclass of _Event" - ) + def _register_subclass(cls, subclass: type[command.Command]) -> None: + assert issubclass(subclass, _Event) # Don't register UnknownEvent, no message should be decoded as this if subclass.__name__ == "UnknownEvent": return - cls._instance_types[subclass._instance_type] = subclass + if subclass._instance_type is not None: + cls._instance_types[subclass._instance_type] = subclass @classmethod def get_instance_type_class( cls, instance_type: int - ) -> Optional[Type[_Event]]: + ) -> Optional[type[_Event]]: return cls._instance_types.get(instance_type) @classmethod - def from_event_data(cls, event_data: int) -> Type[_Event]: + def from_event_data(cls, event_data: int) -> Optional[type[_Event]]: """ Takes a given set of event data, and returns a class which is intended to contain that data. For some event types this might just be a mapping, i.e. for events where each possible value represents a different event. For some other events which have data, the returned class could be the same for multiple data. + + If the event_data is not valid, returns None. """ raise NotImplementedError( "'from_event_data()' must be implemented in a subclass" @@ -1166,18 +1208,17 @@ class could be the same for multiple data. @classmethod def from_frame( - cls, - f: frame.Frame, - devicetype: int = 0, - dev_inst_map: DeviceInstanceTypeMapper = None, - ): + cls, f: frame.ForwardFrame, devicetype: int = 0, + dev_inst_map: Optional[command.DITM] = None + ) -> Optional[_Event]: # In 24-bit frames, bit 16 is 0 for "events" if f[16] != 0: - return + return None short_address = None instance_number = None instance_group = None + instance_type = None device_group = None data = f[9:0] @@ -1227,7 +1268,7 @@ def from_frame( instance_group = f[21:17] else: # This message is not an event message - return + return None instance_type_class = cls.get_instance_type_class(instance_type) if instance_type_class is not None: @@ -1252,10 +1293,10 @@ def from_frame( data=data, ) - def __str__(self): + def __str__(self) -> str: return self.__repr__() - def __repr__(self): + def __repr__(self) -> str: rep_str = f"{self.__class__.__name__}(" if self.short_address: rep_str += f"short_address={self.short_address.address}, " @@ -1280,11 +1321,12 @@ class UnknownEvent(_Event): in this library """ - def __init__(self, instance_type: int = 0, **kwargs): + def __init__(self, instance_type: int = 0, **kwargs: Any) -> None: self._instance_type = instance_type super().__init__(**kwargs) - def _set_event_data(self, set_data: Any, set_frame: frame.Frame): + def _set_event_data(self, set_data: Optional[int], + set_frame: frame.Frame) -> None: """ Even though the event information can't be decoded because the event type is not understood, the information can still be stored into @@ -1295,11 +1337,12 @@ def _set_event_data(self, set_data: Any, set_frame: frame.Frame): set_frame[9:0] = set_data @property - def event_data(self): + def event_data(self) -> int: return self._unhandled_data @classmethod - def from_frame(cls, f, devicetype=0, dev_inst_map=None): + def from_frame(cls, f: frame.ForwardFrame, devicetype: int = 0, + dev_inst_map: Optional[command.DITM] = None) -> None: return @@ -1316,7 +1359,7 @@ class AmbiguousInstanceType(_Event): `DiscoverInstanceTypes()`. """ - def __init__(self, **kwargs): + def __init__(self, **kwargs: Any) -> None: short_address = kwargs.pop("short_address") if short_address is None: raise ValueError( @@ -1328,7 +1371,7 @@ def __init__(self, **kwargs): "'instance_number' is required for AmbiguousInstanceType" ) data = kwargs.pop("data", None) - self._unhandled_data = None + self._unhandled_data: Optional[int] = None super().__init__( short_address=short_address, instance_number=instance_number, @@ -1336,10 +1379,11 @@ def __init__(self, **kwargs): ) @classmethod - def from_event_data(cls, event_data: int) -> Type[_Event]: + def from_event_data(cls, event_data: int) -> type[_Event]: return cls - def _set_event_data(self, set_data: Optional[int], set_frame: frame.Frame): + def _set_event_data(self, set_data: Optional[int], + set_frame: frame.Frame) -> None: """ Even though the event information can't be decoded because the instance type is not known, the information can still be stored into @@ -1350,11 +1394,11 @@ def _set_event_data(self, set_data: Optional[int], set_frame: frame.Frame): set_frame[9:0] = set_data @property - def event_data(self): + def event_data(self) -> Optional[int]: return self._unhandled_data def retry_decode( - self, dev_inst_map: DeviceInstanceTypeMapper + self, dev_inst_map: command.DITM ) -> Optional[_Event]: """ Tries to decode this ambiguous instance message, using the additional @@ -1369,8 +1413,9 @@ def retry_decode( additional information then a new event object will be returned, otherwise None """ - retried = command.Command.from_frame( + retried = _Event.from_frame( self.frame, devicetype=self.devicetype, dev_inst_map=dev_inst_map ) if not isinstance(retried, AmbiguousInstanceType): return retried + return None diff --git a/dali/device/general_sensor.py b/dali/device/general_sensor.py index 75e704a7..2c24f099 100644 --- a/dali/device/general_sensor.py +++ b/dali/device/general_sensor.py @@ -1,8 +1,11 @@ """Commands and events from IEC 62386 part 306: General sensors.""" +from __future__ import annotations + +from typing import Optional from enum import IntEnum -from dali import command +from dali import command, frame from dali.device import general # IEC 62386-306 instance type. @@ -196,7 +199,9 @@ class GeneralSensorEvent(general._Event): _event_info = 0x20000 @classmethod - def from_event_data(cls, event_data: int): + def from_event_data( + cls, event_data: int + ) -> Optional[type[GeneralSensorEvent]]: """Return this event class for a measurement event.""" return cls if event_data & 0x200 else None @@ -210,7 +215,7 @@ 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: + def _set_event_data(self, set_data: int, set_frame: frame.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") diff --git a/dali/device/helpers.py b/dali/device/helpers.py index 309f3c71..5fa93f45 100644 --- a/dali/device/helpers.py +++ b/dali/device/helpers.py @@ -10,46 +10,16 @@ from dali.command import Command, Response from dali.device.general import ( QueryDeviceStatus, - QueryDeviceStatusResponse, QueryInstanceEnabled, QueryInstanceType, QueryNumberOfInstances, StartQuiescentMode, StopQuiescentMode, ) -from dali.exceptions import MissingResponse, ResponseError from dali.frame import BackwardFrameError from dali.sequences import progress as seq_progress, sleep -def check_bad_rsp(r: Response | None) -> bool: - """ - Checks if a response is "bad", i.e. if it is missing, is an error, or does - not have a value when one is expected. - - :param r: A Response object to check - :return: A boolean, True if the response is "bad" - """ - if not r: - return True - if isinstance(r.raw_value, BackwardFrameError): - return True - try: - # The 'value' property raises an exception if a value is expected but - # not present (except for NumericResponse) - val = r.value - # A NumericResponse uses strings to indicate error state - if isinstance(val, str): - if "missing" in val: - return True - if "framing error" in val: - return True - except (MissingResponse, ResponseError): - return True - - return False - - class DeviceInstanceTypeMapper: """ Part 103 defines five different event message addressing schemes, as per @@ -69,19 +39,20 @@ class DeviceInstanceTypeMapper: through the `from_frame()` method. """ - def __init__(self, initial=None): + def __init__(self, initial: Optional[dict[tuple[int, int], int]] = None + ) -> None: """ Creates a new DeviceInstanceTypeMapper object, optionally with preloaded mappings as defined by `initial`. :param initial: A dict of data to preload into the mapping """ - self._mapping: dict[(int, int), int] = {} + self._mapping: dict[tuple[int, int], int] = {} if initial: self._mapping = initial @property - def mapping(self) -> dict[(int, int), int]: + def mapping(self) -> dict[tuple[int, int], int]: return self._mapping def autodiscover( @@ -89,7 +60,7 @@ def autodiscover( addresses: int | tuple[int, int] | Iterable[int] = (0, 63), quiescent_settle: float = 0.2, status_attempts: int = 3, - ) -> Generator[Command, Response, None]: + ) -> Generator[Command | sleep | seq_progress, Response, None]: """ A generator sequence to scan a DALI bus for control device instances, and query their types. This information is stored within this @@ -140,27 +111,23 @@ def autodiscover( # stop probing it immediately. for _attempt in range(status_attempts): rsp = yield QueryDeviceStatus(device=addr) - if not check_bad_rsp(rsp): + assert isinstance(rsp, QueryDeviceStatus.response) + if rsp.valid: break if not isinstance(getattr(rsp, "raw_value", None), BackwardFrameError): break - if check_bad_rsp(rsp): + assert isinstance(rsp, QueryDeviceStatus.response) + if not rsp.valid: continue - if isinstance(rsp, QueryDeviceStatusResponse): - # Make sure the status is OK - if ( - rsp.short_address_is_mask - or rsp.reset_state - ): - continue - else: - # If the response isn't QueryDeviceStatusResponse then - # something is wrong + # Make sure the status is OK + if rsp.short_address_is_mask \ + or rsp.reset_state: continue # Find out how many instances the device has rsp = yield QueryNumberOfInstances(device=addr) - if check_bad_rsp(rsp): + assert isinstance(rsp, QueryNumberOfInstances.response) + if not rsp.valid: continue # Instance numbers are 0..31 (IEC 62386-103), so a device can have # at most 32 instances. Cap the count defensively: a device @@ -168,30 +135,32 @@ def autodiscover( # 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) + num_inst = min(rsp.as_integer, 32) # For each instance, check it is enabled and then query the type for inst_int in range(num_inst): inst = InstanceNumber(inst_int) rsp = yield QueryInstanceEnabled(device=addr, instance=inst) - if check_bad_rsp(rsp): + assert isinstance(rsp, QueryInstanceEnabled.response) + if not rsp.valid: continue - if not rsp.value: + if not rsp.as_boolean: # Skip if not enabled continue rsp = yield QueryInstanceType(device=addr, instance=inst) - if check_bad_rsp(rsp): + assert isinstance(rsp, QueryInstanceType.response) + if not rsp.valid: continue yield seq_progress( - message=f"A²{addr_int} I{inst_int} type: {rsp.value}" + message=f"A²{addr_int} I{inst_int} type: {rsp.as_integer}" ) # Add the type to the device/instance map self.add_type( short_address=addr, instance_number=inst, - instance_type=rsp.value, + instance_type=rsp.as_integer, ) # End quiescent mode @@ -262,5 +231,5 @@ def clear(self) -> None: """ self._mapping = {} - def __repr__(self): + def __repr__(self) -> str: return f"{self.__class__.__name__}({self._mapping})" diff --git a/dali/device/light.py b/dali/device/light.py index 2e6f6ef4..350ec511 100644 --- a/dali/device/light.py +++ b/dali/device/light.py @@ -4,6 +4,7 @@ """ from __future__ import annotations +from typing import Optional from dali import command, frame from dali.device import general @@ -27,22 +28,23 @@ class LightEvent(general._Event): _event_info = 0 @classmethod - def _register_subclass(cls, subclass): + def _register_subclass(cls, subclass: type[command.Command]) -> None: raise RuntimeError( "Called LightEvent._register_subclass()! There should be no " "subclasses of LightEvent." ) @classmethod - def from_event_data(cls, event_data: int): + def from_event_data(cls, event_data: int) -> type[LightEvent]: # There is only one class for LightEvent, which contains the illuminance return LightEvent @property - def event_data(self): + def event_data(self) -> int: return self._event_info - def _set_event_data(self, set_data: int, set_frame: frame.Frame): + def _set_event_data(self, set_data: Optional[int], + set_frame: frame.Frame) -> None: if not isinstance(set_data, int): raise ValueError("LightEvent requires 'data' to be set as an 'int'") @@ -80,8 +82,7 @@ class _LightCommand(general._StandardInstanceCommand): An extension of the standard commands, addressed to a light sensor control device instance """ - - _opcode = None + pass class SetReportTimer(_LightCommand): diff --git a/dali/device/occupancy.py b/dali/device/occupancy.py index 8c70b809..4da1813f 100644 --- a/dali/device/occupancy.py +++ b/dali/device/occupancy.py @@ -4,9 +4,7 @@ """ from __future__ import annotations -from typing import NamedTuple -# TODO: If support for Python 3.7 is dropped then a Literal type can be used -# from typing import Literal +from typing import NamedTuple, Literal, Optional from dali import command, frame from dali.device import general @@ -50,28 +48,26 @@ class EventData(NamedTuple): movement: bool occupied: bool repeat: bool = False - # TODO: If Literal is supported then this type hint can be improved - # sensor_type: Literal["presence", "movement"] = "movement" - sensor_type: str = "movement" + sensor_type: Literal["presence", "movement"] = "movement" # "3" corresponds with "Part 303", as per Table 4 of IEC 62386 part 103 _instance_type = instance_type # Occupancy events contain some extra information, wrapped in a dataclass - _extra_data: EventData = None + _extra_data: EventData # Event info is set based on the named tuple passed in as data _event_info = 0 @classmethod - def _register_subclass(cls, subclass): + def _register_subclass(cls, subclass: type[command.Command]) -> None: raise RuntimeError( "Called OccupancyEvent._register_subclass()! There should be no " "subclasses of OccupancyEvent." ) @classmethod - def from_event_data(cls, event_data: int): + def from_event_data(cls, event_data: int) -> Optional[type[OccupancyEvent]]: # Check that the data is valid for an occupancy sensor - only the lowest # 4 bits are used, make sure the high 6 bits are zero if event_data | 0b1111 != 0b1111: @@ -80,12 +76,14 @@ 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( - self, set_data: int | EventData, set_frame: frame.Frame - ): + self, set_data: int | EventData, + set_frame: frame.ForwardFrame + ) -> None: + sensor: Literal["presence", "movement"] if set_data is None: raise ValueError("OccupancyEvent requires 'data' to be set") @@ -108,7 +106,7 @@ def _set_event_data( movement=movement, occupied=occupied, repeat=repeat, - sensor_type=sensor, # noqa + sensor_type=sensor, ) # If data is already an OccupancyEvent.EventData then just store that @@ -180,8 +178,7 @@ class _OccupancyCommand(general._StandardInstanceCommand): An extension of the standard commands, addressed to an occupancy control device instance """ - - _opcode = None + pass class CatchMovement(_OccupancyCommand): diff --git a/dali/device/pushbutton.py b/dali/device/pushbutton.py index 79ae27e2..87dba513 100644 --- a/dali/device/pushbutton.py +++ b/dali/device/pushbutton.py @@ -4,8 +4,7 @@ """ from __future__ import annotations -from typing import Dict, Type - +from typing import Optional from dali import command from dali.device import general @@ -23,19 +22,15 @@ class _PushbuttonEvent(general._Event): # "1" corresponds with "Part 301", as per Table 4 of IEC 62386 part 103 _instance_type = instance_type - _event_classes: Dict[int, Type[_PushbuttonEvent]] = {} + _event_classes: dict[int, type[_PushbuttonEvent]] = {} @classmethod - def _register_subclass(cls, subclass): - if not issubclass(subclass, _PushbuttonEvent): - raise RuntimeError( - "Somehow called _PushbuttonEvent._register_subclass() " - "without a subclass of _PushbuttonEvent" - ) + def _register_subclass(cls, subclass: type[command.Command]) -> None: + assert issubclass(subclass, _PushbuttonEvent) cls._event_classes[subclass._event_info] = subclass @classmethod - def from_event_data(cls, event_data: int): + def from_event_data(cls, event_data: int) -> Optional[type[_PushbuttonEvent]]: return cls._event_classes.get(event_data) @@ -189,8 +184,7 @@ class _PushbuttonCommand(general._StandardInstanceCommand): An extension of the standard commands, addressed to a push button control device instance """ - - _opcode = None + pass class SetShortTimer(_PushbuttonCommand): diff --git a/dali/device/sequences.py b/dali/device/sequences.py index aaac752c..ca7e0355 100644 --- a/dali/device/sequences.py +++ b/dali/device/sequences.py @@ -3,8 +3,8 @@ """ from __future__ import annotations -import types -from typing import Generator, Optional, Type +from typing import Generator, Optional, Literal, Iterable +import inspect from dali.address import DeviceBroadcast, DeviceShort, InstanceNumber from dali.command import Command, Response @@ -39,19 +39,18 @@ VerifyShortAddress, Withdraw, ) -from dali.device.helpers import check_bad_rsp from dali.exceptions import DALISequenceError, ProgramShortAddressFailure from dali.sequences import progress, sleep def SetEventSchemes( - device: DeviceShort, - instance: InstanceNumber, + device: DeviceShort | int, + instance: InstanceNumber | int, scheme: EventScheme, ) -> Generator[ Command, Optional[Response], - Optional[QueryEventSchemeResponse], + QueryEventSchemeResponse, ]: """ A generator sequence to set the event scheme of a device instance. Use @@ -85,15 +84,12 @@ def SetEventSchemes( # ValueError if not EventScheme(pos) - rsp = yield DTR0(pos) - if rsp is not None: - return + yield DTR0(pos) - rsp = yield SetEventScheme(device=device, instance=instance) - if rsp is not None: - return + yield SetEventScheme(device=device, instance=instance) rsp = yield QueryEventScheme(device=device, instance=instance) + assert isinstance(rsp, QueryEventScheme.response) return rsp @@ -123,9 +119,10 @@ def SetEventFilters( ) ``` """ - if not isinstance(filter_value, int): + if not isinstance(filter_value, InstanceEventFilter): raise TypeError( - f"'filter_value' must be an int, not {type(filter_value)}" + f"'filter_value' must be an InstanceEventFilter, not " + f"{type(filter_value)}" ) # Although the proper types are expected, ints are common enough for # addresses and their meaning is unambiguous in this context @@ -134,57 +131,48 @@ def SetEventFilters( if isinstance(instance, int): instance = InstanceNumber(instance) - if isinstance(filter_value, InstanceEventFilter): - uses_dtr1 = filter_value.dali_width() > 8 - uses_dtr2 = filter_value.dali_width() > 16 - else: - uses_dtr1 = False - uses_dtr2 = False + uses_dtr1 = filter_value.dali_width() > 8 + uses_dtr2 = filter_value.dali_width() > 16 # The values in InstanceEventFilter are already mapped out to the # corresponding bits, through the inheritance from IntFlag lo, md, hi = filter_value.to_bytes(3, "little") # Set the various values into the DTRs - rsp = yield DTR0(lo) - if rsp is not None: - return + yield DTR0(lo) if uses_dtr1: - rsp = yield DTR1(md) - if rsp is not None: - return - if uses_dtr2 > 16: - rsp = yield DTR2(hi) - if rsp is not None: - return + yield DTR1(md) + if uses_dtr2: + yield DTR2(hi) # Set the filters - rsp = yield SetEventFilter(device=device, instance=instance) - if rsp is not None: - return + yield SetEventFilter(device=device, instance=instance) # Read back the values that were set rsp = yield QueryEventFilterL(device=device, instance=instance) - if check_bad_rsp(rsp): - return - lo = rsp.value + assert isinstance(rsp, QueryEventFilterL.response) + if not rsp.valid: + return None # XXX Should we be raising an exception here? + lo = rsp.as_integer if uses_dtr1: rsp = yield QueryEventFilterM(device=device, instance=instance) - if check_bad_rsp(rsp): - return - md = rsp.value + assert isinstance(rsp, QueryEventFilterM.response) + if not rsp.valid: + return None + md = rsp.as_integer if uses_dtr2: rsp = yield QueryEventFilterH(device=device, instance=instance) - if check_bad_rsp(rsp): - return - hi = rsp.value + assert isinstance(rsp, QueryEventFilterH.response) + if not rsp.valid: + return None + hi = rsp.as_integer return filter_value.__class__(int.from_bytes((lo, md, hi), "little")) def QueryEventFilters( - device: DeviceShort, - instance: InstanceNumber, - filter_type: types.ModuleType | Type[InstanceEventFilter], + device: DeviceShort | int, + instance: InstanceNumber | int, + filter_type: type[InstanceEventFilter], ) -> Generator[Command, Optional[Response], Optional[InstanceEventFilter]]: """ A generator sequence to query the event filters of a device instance. @@ -195,8 +183,8 @@ def QueryEventFilters( :param device: A DeviceShort address to target :param instance: An InstanceNumber to target - :param filter_type: Either the module of the type being targeted, or a - specific InstanceEventFilter type to use, e.g. `dali.device.pushbutton` + :param filter_type: An InstanceEventFilter type to use, e.g. + `dali.device.pushbutton.InstanceEventFilter` :return: A generator function, to use with e.g. `driver.run_sequence()` Example: @@ -213,10 +201,18 @@ def QueryEventFilters( ) ``` """ - if hasattr(filter_type, "InstanceEventFilter"): - filter_type = getattr(filter_type, "InstanceEventFilter") - elif not issubclass(filter_type, InstanceEventFilter): + # Deprecated support for passing a module as filter_type + if inspect.ismodule(filter_type): + if hasattr(filter_type, "InstanceEventFilter"): + filter_type = getattr(filter_type, "InstanceEventFilter") + else: + raise TypeError( + "If 'filter_type' is a module, it must have an " + "InstanceEventFilter attribute") + + if not issubclass(filter_type, InstanceEventFilter): raise TypeError("'filter_type' must be an InstanceEventFilter subclass") + # Although the proper types are expected, ints are common enough for # addresses and their meaning is unambiguous in this context if isinstance(device, int): @@ -229,19 +225,22 @@ def QueryEventFilters( hi = 0 rsp = yield QueryEventFilterL(device=device, instance=instance) - if check_bad_rsp(rsp): - return - lo = rsp.value + assert isinstance(rsp, QueryEventFilterL.response) + if not rsp.valid: + return None # should we raise an exception instead? + lo = rsp.as_integer if filter_type.dali_width() > 8: rsp = yield QueryEventFilterM(device=device, instance=instance) - if check_bad_rsp(rsp): - return - md = rsp.value + assert isinstance(rsp, QueryEventFilterM.response) + if not rsp.valid: + return None + md = rsp.as_integer if filter_type.dali_width() > 16: rsp = yield QueryEventFilterH(device=device, instance=instance) - if check_bad_rsp(rsp): - return - hi = rsp.value + assert isinstance(rsp, QueryEventFilterH.response) + if not rsp.valid: + return None + hi = rsp.as_integer return filter_type(int.from_bytes((lo, md, hi), "little")) @@ -250,7 +249,7 @@ def query_input_value( device: DeviceShort, instance: InstanceNumber, resolution: Optional[int] = None -) -> Generator[Command, Optional[Response], Optional[int]]: +) -> Generator[Command, Optional[Response], int]: """ A generator sequence to retrieve full sensor value from a part-103 control device. Use with an appropriate DALI driver instance, through the `run_sequence()` @@ -268,22 +267,25 @@ def query_input_value( instance = InstanceNumber(instance) if resolution is None: - resolution = yield QueryResolution(device, instance) - if check_bad_rsp(resolution): + rsp = yield QueryResolution(device, instance) + assert isinstance(rsp, QueryResolution.response) + if not rsp.valid: raise DALISequenceError("query_input_value: QueryResolution failed") - resolution = resolution.value + resolution = rsp.as_integer - value = yield QueryInputValue(device, instance) - if check_bad_rsp(value): + rsp = yield QueryInputValue(device, instance) + assert isinstance(rsp, QueryInputValue.response) + if not rsp.valid: raise DALISequenceError("query_input_value: QueryInputValue failed") - value = value.value + value = rsp.as_integer while resolution > 8: resolution -= 8 value <<= 8 - chunk = yield QueryInputValueLatch(device, instance) - if check_bad_rsp(chunk): + rsp = yield QueryInputValueLatch(device, instance) + assert isinstance(rsp, QueryInputValueLatch.response) + if not rsp.valid: raise DALISequenceError("query_input_value: QueryInputValueLatch failed") - value += chunk.value + value += rsp.as_integer if resolution > 0: # Strip the repeated trailing bytes as per IEC 62386-103:2014, part 9.7.2 @@ -301,7 +303,7 @@ def query_input_value( def _find_next( low: int, high: int -) -> Generator[Command, Optional[Response], Optional[int | str]]: +) -> Generator[Command, Optional[Response], Optional[Literal["clash"] | int]]: """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 @@ -313,13 +315,14 @@ def _find_next( yield SearchAddrL(high & 0xFF) r = yield Compare() + assert isinstance(r, Compare.response) if low == high: - if r.value is True: - return "clash" if r.raw_value.error else low + if r.as_boolean is True: + return "clash" if r.raw_value and r.raw_value.error else low return None - if r.value is True: + if r.as_boolean is True: midpoint = (low + high) // 2 res = yield from _find_next(low, midpoint) if res is not None: @@ -330,10 +333,11 @@ def _find_next( def Commissioning( - available_addresses: Optional[list[int]] = None, + available_addresses: Optional[Iterable[int]] = None, readdress: bool = False, dry_run: bool = False, -) -> Generator[Command, Optional[Response], list[DeviceShort]]: +) -> Generator[Command | sleep | progress, Optional[Response], + list[DeviceShort]]: """Assign short addresses to 24-bit (part 103) control devices. This mirrors :func:`dali.sequences.Commissioning` for control gear, using @@ -350,8 +354,7 @@ def Commissioning( 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. + :return: The list of short addresses in use after the sequence completes. """ used_addresses: list[DeviceShort] = [] if available_addresses is None: @@ -372,7 +375,8 @@ def Commissioning( # 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): + assert isinstance(in_use, QueryDeviceStatus.response) + if in_use.valid: available_addresses.remove(a) used_addresses.append(DeviceShort(a)) yield progress(message=f"Available addresses: {available_addresses}") @@ -395,21 +399,22 @@ def Commissioning( # Randomise can take up to 200ms to settle. yield sleep(0.2) - low = 0 + low: Optional[int] = 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" - ) + new_low = yield from _find_next(low, high) + if new_low == "clash": + yield progress(message="Multiple ballasts picked the same " + "random address; restarting") break - if low is None: + elif new_low is None: + low = None finished = True break + else: + low = new_low yield progress(message=f"Device found at address {low:#x}") if available_addresses: new_addr = available_addresses.pop(0) @@ -424,7 +429,8 @@ def Commissioning( ) yield ProgramShortAddress(new_addr) r = yield VerifyShortAddress(new_addr) - if r.value is not True: + assert isinstance(r, VerifyShortAddress.response) + if r.as_boolean is not True: failed_address = new_addr finished = True break diff --git a/dali/driver/base.py b/dali/driver/base.py index 714df187..c8a4d369 100644 --- a/dali/driver/base.py +++ b/dali/driver/base.py @@ -1,6 +1,14 @@ ############################################################################### # driver contracts ############################################################################### +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Callable, Optional + +if TYPE_CHECKING: + from dali.command import Command + from dali.frame import Frame + class DALIDriver: """Object for handling wrapped DALI frames sent to and received from @@ -10,10 +18,10 @@ class DALIDriver: communicate with a DALI bus. """ - backend = None + backend: Optional[Backend] = None """``Backend`` instance used for reading and writing.""" - def construct(self, command): + def construct(self, command: Command) -> bytes: """Construct raw data containing the packed DALI frame from command which can be sent to DALI driver. @@ -23,7 +31,7 @@ def construct(self, command): raise NotImplementedError( 'Abstract ``DaliDriver`` does not implement ```construct`') - def extract(self, data): + def extract(self, data: bytes) -> Frame: """Extract and return DALI Frame from data which has been received from DALI driver. @@ -39,7 +47,7 @@ class SyncDALIDriver(DALIDriver): expecting and returning a result. """ - def send(self, command, timeout=None): + def send(self, command: Command, timeout: Optional[int] = None) -> Any: """Send command to gateway and return response. @param command: DALI command to send. @@ -55,11 +63,16 @@ class AsyncDALIDriver(DALIDriver): DALI drivers. """ - dispatcher = None + dispatcher: Optional[Callable[..., Any]] = None """Callable used for dispatching incoming forward frames. """ - def send(self, command, callback=None, **kw): + def send( + self, + command: Command, + callback: Optional[Callable[..., Any]] = None, + **kw: Any, + ) -> None: """Send command to gateway. @param command: DALI command to send. @@ -70,7 +83,7 @@ def send(self, command, callback=None, **kw): raise NotImplementedError( 'Abstract ``AsyncDALIDriver`` does not implement ``send``') - def receive(self, frame): + def receive(self, frame: Frame) -> None: """Receive DALI Frame. If a forward frame is received, ``self.dispatcher`` is used for @@ -93,15 +106,15 @@ class Backend: """Object representing a backend. """ - def read(self, timeout=None): + def read(self, timeout: Optional[int] = None) -> Any: raise NotImplementedError( 'Abstract ``Backend`` does not implement ``read``') - def write(data): + def write(self, data: bytes) -> None: raise NotImplementedError( 'Abstract ``Backend`` does not implement ``write``') - def close(self): + def close(self) -> None: raise NotImplementedError( 'Abstract ``Backend`` does not implement ``close``') @@ -111,11 +124,11 @@ class Listener(Backend): incoming frames. """ - driver = None + driver: Optional[AsyncDALIDriver] = None """``AsyncDALIDriver`` instance. """ - def listen(self): + def listen(self) -> None: """Listen to backend and dispatch receiced data to ``self.driver``. """ raise NotImplementedError( diff --git a/dali/driver/daliserver.py b/dali/driver/daliserver.py index 6fdd8538..b53b05e3 100644 --- a/dali/driver/daliserver.py +++ b/dali/driver/daliserver.py @@ -1,4 +1,6 @@ -from dali.command import Command +from typing import Any, Optional + +from dali.command import Command, Response from dali.exceptions import CommunicationError import dali.frame import logging @@ -19,23 +21,23 @@ class DaliServer: 90e34a0cd2945dc7a15681f11647e708f858521e or later. """ - def __init__(self, host="localhost", port=55825, - multiple_frames_per_connection=False): + def __init__(self, host: str = "localhost", port: int = 55825, + multiple_frames_per_connection: bool = False) -> None: self._target = (host, port) - self._s = None + self._s: Optional[socket.socket] = None self._multiple_frames_per_connection = multiple_frames_per_connection - def __enter__(self): + def __enter__(self) -> "DaliServer": if self._multiple_frames_per_connection: self._s = socket.create_connection(self._target) return self - def __exit__(self, *vpass): - if self._multiple_frames_per_connection: + def __exit__(self, *vpass: Any) -> None: + if self._s is not None: self._s.close() self._s = None - def send(self, command): + def send(self, command: Command) -> Optional[Response]: if self._s: s = self._s else: @@ -48,7 +50,7 @@ def send(self, command): command, " (twice)" if command.sendtwice else "")) # Set a default result which may be used if the first send fails - result = "\x02\xff\x00\x00" + result = b"\x02\xff\x00\x00" try: s.send(message) @@ -69,7 +71,7 @@ def send(self, command): return response - def unpack_response(self, command, result): + def unpack_response(self, command: Command, result: bytes) -> Optional[Response]: """Unpack result from the given bytestream and creates the corresponding response object diff --git a/dali/driver/hid.py b/dali/driver/hid.py index 83a07a19..2efd4c4f 100644 --- a/dali/driver/hid.py +++ b/dali/driver/hid.py @@ -11,6 +11,7 @@ import random import glob import warnings +from typing import Any, Callable, Generator, Optional from dali.exceptions import UnsupportedFrameTypeError, CommunicationError from dali.sequences import sleep as seq_sleep from dali.sequences import progress as seq_progress @@ -21,38 +22,40 @@ import dali.gear from dali.gear.general import EnableDeviceType -def _hex(b): +def _hex(b: bytes) -> str: return ''.join("%02X" % x for x in b) -def _single_command_sequence(cmd): +def _single_command_sequence( + cmd: dali.command.Command, +) -> Generator[dali.command.Command, Optional[dali.command.Response], Optional[dali.command.Response]]: """Wrap a single command as a trivial sequence that returns its response.""" return (yield cmd) class _callback: """Helper class for callback registration """ - def __init__(self, parent): + def __init__(self, parent: Any) -> None: self._parent = parent - self._callbacks = {} + self._callbacks: dict[Any, Callable[..., Any]] = {} class _callback_handle: """Callback handle Call unregister() to remove this callback. """ - def __init__(self, callback): + def __init__(self, callback: "_callback") -> None: self._callback = callback - def unregister(self): + def unregister(self) -> None: del self._callback._callbacks[self] - def register(self, func): + def register(self, func: Callable[..., Any]) -> "_callback._callback_handle": wrapper = self._callback_handle(self) self._callbacks[wrapper] = func return wrapper - def _invoke(self, *args): + def _invoke(self, *args: Any) -> None: for func in self._callbacks.values(): asyncio.get_running_loop().call_soon(func, self._parent, *args) @@ -60,8 +63,14 @@ def _invoke(self, *args): class hid: """Shared code for drivers that work with HID devices """ - def __init__(self, path, reconnect_interval=1, reconnect_limit=None, - glob=False, dev_inst_map=None): + _f: Optional[int] + _reconnect_task: Optional[asyncio.Task[None]] + firmware_version: Optional[str] + serial: Optional[str] + + def __init__(self, path: str, reconnect_interval: float = 1, + reconnect_limit: Optional[int] = None, + glob: bool = False, dev_inst_map: Any = None) -> None: self._log = logging.getLogger() self._path = path self._reconnect_interval = reconnect_interval @@ -107,7 +116,7 @@ def __init__(self, path, reconnect_interval=1, reconnect_limit=None, self.firmware_version = None self.serial = None - def connect(self): + def connect(self) -> bool: """Attempt to connect to the device. Attempts to open the device. If this fails, schedules a @@ -152,7 +161,7 @@ def connect(self): self.connection_status_callback._invoke("connected") return True - async def _reconnect(self): + async def _reconnect(self) -> None: self._reconnect_count += 1 if self._reconnect_limit is not None \ and self._reconnect_count > self._reconnect_limit: @@ -165,7 +174,7 @@ async def _reconnect(self): self._reconnect_task = None self.connect() - def disconnect(self, reconnect=False): + def disconnect(self, reconnect: bool = False) -> None: self._log.debug("disconnecting") if self._reconnect_task: self._reconnect_task.cancel() @@ -180,7 +189,10 @@ def disconnect(self, reconnect=False): if reconnect: self._reconnect_task = asyncio.ensure_future(self._reconnect()) - async def send(self, msg, *, priority=5, progress=None, exceptions=None): + async def send(self, msg: dali.command.Command | Generator[Any, Any, Any], *, + priority: int = 5, + progress: Optional[Callable[..., Any]] = None, + exceptions: Optional[bool] = None) -> Any: """Send a command or run a sequence as a single transaction A bare Command is wrapped in a trivial sequence, so a single command @@ -246,7 +258,8 @@ async def send(self, msg, *, priority=5, progress=None, exceptions=None): self.transaction_lock.release() seq.close() - async def power_supply(self, supply_on, in_transaction=False, exceptions=None): + async def power_supply(self, supply_on: bool, in_transaction: bool = False, + exceptions: Optional[bool] = None) -> Any: """ TODO """ @@ -269,7 +282,8 @@ async def power_supply(self, supply_on, in_transaction=False, exceptions=None): if not in_transaction: self.transaction_lock.release() - async def run_sequence(self, seq, progress=None): + async def run_sequence(self, seq: Generator[Any, Any, Any], + progress: Optional[Callable[..., Any]] = None) -> Any: """Deprecated alias for send(), which now accepts commands and sequences """ warnings.warn( @@ -279,7 +293,7 @@ async def run_sequence(self, seq, progress=None): ) return await self.send(seq, progress=progress) - def _initialise_device(self): + def _initialise_device(self) -> None: """Send any device-specific initialisation commands """ # Some devices may need to send initialisation commands and await @@ -287,7 +301,7 @@ def _initialise_device(self): # they set self.connected once initialisation is complete self.connected.set() - def _shutdown_device(self): + def _shutdown_device(self) -> None: """Shut down everything that is waiting for the device The device has gone away. Everything that is waiting for the @@ -295,7 +309,8 @@ def _shutdown_device(self): """ pass - def _reader(self): + def _reader(self) -> None: + assert self._f is not None try: # No need to retry on InterruptedError since 3.5 data = os.read(self._f, 64) @@ -307,10 +322,26 @@ def _reader(self): return self._handle_read(data) - def _handle_read(self, data): + def _handle_read(self, data: bytes) -> None: pass + def _raw_write(self, buf: bytes) -> None: + assert self._f is not None + os.write(self._f, buf) + + async def _send_raw( + self, command: dali.command.Command + ) -> Optional[dali.command.Response]: + raise NotImplementedError + + async def _power_supply(self, supply_on: bool) -> Optional[dali.command.Response]: + raise NotImplementedError + class tridonic(hid): + _outstanding: dict[int, tuple[asyncio.Event, list[Any]]] + _bus_watch_data: list[bytes] + _bus_watch_task: Optional[asyncio.Task[None]] + # Commands sent to the interface # cmd, seq, ctrl, mode, frame (4 bytes), dtr, prio, devtype _cmdtmpl = struct.Struct(">4B4s3B53x") @@ -380,7 +411,7 @@ class tridonic(hid): _BUS_STATUS_DALI_MODE = 6 @staticmethod - def _seqnum(i): + def _seqnum(i: int) -> Generator[int, None, None]: """Sequence number generator """ while True: @@ -389,7 +420,7 @@ def _seqnum(i): if i > 0xff: i = 1 - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self._log = self._log.getChild("tridonic") @@ -409,16 +440,17 @@ def __init__(self, *args, **kwargs): self._bus_watch_data_available = asyncio.Event() self._bus_watch_data = [] - def _raw_write(self, buf: bytes): + def _raw_write(self, buf: bytes) -> None: self._log.debug(f"_raw_write: {_hex(buf[0:self._cmdtmpl_useful_size])}") + assert self._f is not None os.write(self._f, buf) - def _initialise_device(self): + def _initialise_device(self) -> None: # Read firmware version; pick up the reply in _handle_read self._raw_write(self._cmd( tridonic._CMD_INIT, tridonic._CMD_INIT_READVERSION)) - async def _power_supply(self, supply_on): + async def _power_supply(self, supply_on: bool) -> Optional[dali.command.Response]: await self.connected.wait() async with self._command_semaphore: data = self._cmd(self._CMD_POWERSUPPLY, @@ -432,9 +464,10 @@ async def _power_supply(self, supply_on): self._log.debug("fail on transmit, disconnecting") self.disconnect(reconnect=True) raise CommunicationError + return None @staticmethod - def _command_mode(frame): + def _command_mode(frame: dali.frame.Frame) -> int: if len(frame) == 8: return tridonic._SEND_MODE_DALI8 if len(frame) == 16: @@ -443,7 +476,7 @@ def _command_mode(frame): return tridonic._SEND_MODE_DALI24 raise UnsupportedFrameTypeError - async def _send_raw(self, command): + async def _send_raw(self, command: dali.command.Command) -> Optional[dali.command.Response]: frame = command.frame if len(frame) not in (16, 24): raise UnsupportedFrameTypeError @@ -452,7 +485,7 @@ async def _send_raw(self, command): seq = next(self._cmd_seq) self._log.debug(f"Sending with {seq=:02x}") event = asyncio.Event() - messages = [] + messages: list[Any] = [] # If seq is in self._outstanding this means we've wrapped # around the whole sequence number space with an event # still outstanding: clearly a bug! @@ -473,7 +506,7 @@ async def _send_raw(self, command): raise CommunicationError outstanding_transmissions = 2 if command.sendtwice else 1 - response = None + response: dali.frame.BackwardFrame | dali.frame.BackwardFrameError | str | None = None while outstanding_transmissions or response is None: self._log.debug(f"_send_raw waiting for {outstanding_transmissions=} " f"{response=!s}") @@ -515,11 +548,12 @@ async def _send_raw(self, command): del self._outstanding[seq], event, messages if command.response: # Construct response and return it - if response == "no": + if isinstance(response, str): return command.response(None) return command.response(response) + return None - async def _bus_watch(self): + async def _bus_watch(self) -> None: # Why is this a task, and not just run from _handle_read()? # It's so that when we see a forward frame on the bus that # didn't originate with us, we can apply a timeout after which @@ -557,6 +591,7 @@ async def _bus_watch(self): if origin not in (self._MODE_OBSERVE, self._MODE_RESPONSE): self._log.warning("bus_watch: unexpected packet mode, ignoring") continue + frame: dali.frame.ForwardFrame | dali.frame.BackwardFrame | dali.frame.BackwardFrameError | str if rtype == self._RESPONSE_FRAME_DALI16: frame = dali.frame.ForwardFrame(16, raw_frame) elif rtype == self._RESPONSE_FRAME_DALI24: @@ -612,20 +647,21 @@ async def _bus_watch(self): else: self._log.error("Unexpected response waiting for retransmit of config command, frame = %s", frame) elif current_command.response: + response_type = current_command.response # We are waiting for a response if timeout or frame == "no": # The response is "No". self._log.debug("Command %s response \'No\'", current_command) self.bus_traffic._invoke( - current_command, current_command.response(None), False) + current_command, response_type(None), False) current_command = None continue elif isinstance(frame, dali.frame.BackwardFrame): # There's a response self._log.debug("Command %s response %s", - current_command, current_command.response(frame)) + current_command, response_type(frame)) self.bus_traffic._invoke( - current_command, current_command.response(frame), False) + current_command, response_type(frame), False) current_command = None continue else: @@ -634,7 +670,7 @@ async def _bus_watch(self): self._log.debug("Command %s response \'No\' (on new frame)", current_command) self.bus_traffic._invoke( - current_command, current_command.response(None), False) + current_command, response_type(None), False) current_command = None # If we reach here, there is no current_command and there @@ -646,6 +682,7 @@ async def _bus_watch(self): command = dali.command.from_frame( frame, devicetype=devicetype, dev_inst_map=self.dev_inst_map) + assert command is not None devicetype = 0 if command.sendtwice or command.response: # We need more information. Stash the command and wait. @@ -661,7 +698,7 @@ async def _bus_watch(self): self._log.debug("Unexpected backward frame %s", frame) # self._log.debug("End of loop") - def _handle_read(self, data): + def _handle_read(self, data: bytes) -> None: self._log.debug("_handle_read %s", _hex(data[0:self._resptmpl_useful_size])) if data[0] == self._MODE_INFO: # Response to initialisation command @@ -704,7 +741,7 @@ def _handle_read(self, data): else: self._log.debug("Unknown response mode %x", data[0]) - def _shutdown_device(self): + def _shutdown_device(self) -> None: # All outstanding commands need to be woken up and told they've failed for event, messages in self._outstanding.values(): messages.append("fail") @@ -721,8 +758,9 @@ def _shutdown_device(self): self.serial = None @staticmethod - def _cmd(cmd, serial, ctrl=0, mode=0, frame=bytes(), dtr0=0, prio=0, - devtype=0): + def _cmd(cmd: int, serial: int, ctrl: int = 0, mode: int = 0, + frame: bytes = bytes(), dtr0: int = 0, prio: int = 0, + devtype: int = 0) -> bytes: """Return 64 bytes for the specified command """ return tridonic._cmdtmpl.pack( @@ -747,14 +785,14 @@ class hasseb(hid): _OK = 2 _INVALID_ANSWER = 3 - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self._log = self._log.getChild("hasseb") self._command_lock = asyncio.Lock() self._response_available = asyncio.Event() - self._response = None + self._response: Any = None - async def _send_raw(self, command): + async def _send_raw(self, command: dali.command.Command) -> Optional[dali.command.Response]: frame = command.frame if len(frame) != 16: raise UnsupportedFrameTypeError @@ -786,7 +824,7 @@ async def _send_raw(self, command): elif self._response[0] == self._OK: response = command.response(dali.frame.BackwardFrame(self._response[1])) elif self._response[0] == self._INVALID_ANSWER: - response = command._response(dali.frame.BackwardFrameError( + response = command.response(dali.frame.BackwardFrameError( self._response[1])) else: self._log.debug("Unknown response code %x", self._response[0]) @@ -794,7 +832,7 @@ async def _send_raw(self, command): self.bus_traffic._invoke(command, response, False) return response - def _handle_read(self, data): + def _handle_read(self, data: bytes) -> None: # Response should be two bytes. First byte is status, second # byte is optional response data. The hasseb appears to send # "NO DATA AVAILABLE" reports continuously when it is idle. @@ -802,7 +840,7 @@ def _handle_read(self, data): self._response = data self._response_available.set() - def _shutdown_device(self): + def _shutdown_device(self) -> None: # If there's a command in progress, tell it it's failed self._response = "fail" self._response_available.set() diff --git a/dali/driver/serial.py b/dali/driver/serial.py index 2c5c7290..c5731c20 100644 --- a/dali/driver/serial.py +++ b/dali/driver/serial.py @@ -27,18 +27,18 @@ from enum import Enum from functools import reduce from operator import xor -from typing import Any, Callable, Generator, NamedTuple, Optional +from typing import Any, Callable, Generator, NamedTuple, Optional, Sequence, cast from urllib.parse import ParseResult, urlparse, urlunparse import serialx import dali.gear from dali import command, frame, gear, sequences -from dali.driver import trace_logging # noqa: F401 +from dali.driver.trace_logging import get_logger from dali.device.helpers import DeviceInstanceTypeMapper from dali.exceptions import TransmissionError -_LOG = logging.getLogger("dali.driver") +_LOG = get_logger("dali.driver") # Sentinel placed on the raw DALI response queue when the interface reports a # framing error, so a waiting query returns an error response instead of @@ -48,9 +48,9 @@ _DALI_FRAMING_ERROR = object() -def _drain_queue(queue: asyncio.Queue) -> list: +def _drain_queue(queue: asyncio.Queue[Any]) -> list[Any]: """Remove and return every currently-queued item without blocking.""" - items = [] + items: list[Any] = [] while True: try: items.append(queue.get_nowait()) @@ -65,8 +65,8 @@ def _single_command_sequence( return (yield cmd) -class DistributorQueue(asyncio.Queue): - def __init__(self, parent: Optional[DistributorQueue] = None, *, maxsize=0): +class DistributorQueue(asyncio.Queue[Any]): + def __init__(self, parent: Optional[DistributorQueue] = None, *, maxsize: int = 0) -> None: """ A DistributorQueue provides a way of distributing some object to multiple waiting consumers. The base of a DistributorQueue structure @@ -107,7 +107,7 @@ def __init__(self, parent: Optional[DistributorQueue] = None, *, maxsize=0): def is_parent(self) -> bool: return self._parent is None - def add_handler(self, handler: DistributorQueue): + def add_handler(self, handler: DistributorQueue) -> None: if self.is_parent: self._handlers[hash(handler)] = handler else: @@ -116,16 +116,16 @@ def add_handler(self, handler: DistributorQueue): "Cannot add handler to DistributorQueue with a parent" ) - def del_handler(self, handler: DistributorQueue): + def del_handler(self, handler: DistributorQueue) -> None: self._handlers.pop(hash(handler), None) - def distribute(self, item: Any): + def distribute(self, item: Any) -> None: if self._parent is not None: self.put_nowait(item) for handler in self._handlers.values(): handler.distribute(item) - def __del__(self): + def __del__(self) -> None: if self._parent is not None: self._parent.del_handler(self) @@ -157,9 +157,7 @@ def __init__( "DriverSerialBase cannot be instantiated directly" ) - try: - uri.path - except AttributeError: + if isinstance(uri, str): uri = urlparse(uri) if uri.scheme != self.uri_scheme: @@ -168,19 +166,19 @@ def __init__( f"instance with a scheme of {uri.scheme}" ) - self.uri = uri - self.dev_inst_map: DeviceInstanceTypeMapper = dev_inst_map - if dev_inst_map is None: - self.dev_inst_map = DeviceInstanceTypeMapper() + self.uri: ParseResult = uri + self.dev_inst_map = ( + DeviceInstanceTypeMapper() if dev_inst_map is None else dev_inst_map + ) self._connected = asyncio.Event() # Assigned by the subclass once the asyncio transport protocol exists self._protocol: Any = None self.transaction_lock = asyncio.Lock() - def __repr__(self): + def __repr__(self) -> str: return f'{self.__class__.__name__}("{urlunparse(self.uri)}")' - def __hash__(self): + def __hash__(self) -> int: return hash(self.uri) async def connect(self, *, scan_dev_inst: bool = False) -> None: @@ -213,7 +211,7 @@ def is_connected(self) -> bool: return False if self._protocol is None: return True - return self._protocol.connected.is_set() + return bool(self._protocol.connected.is_set()) async def wait_connected(self) -> None: """ @@ -271,7 +269,7 @@ def new_dali_rx_queue(self) -> DistributorQueue: async def send( self, - msg: command.Command | Generator, + msg: command.Command | Generator[Any, Any, Any], *, priority: int = 5, progress: Optional[Callable[[str | sequences.progress], None]] = None, @@ -506,20 +504,20 @@ class LubaMsgTxConf(NamedTuple): def __init__(self) -> None: super().__init__() - self.transport = None + self.transport: Optional[serialx.SerialTransport] = None self._queue_rx_dali = DistributorQueue() - self._queue_rx_raw_dali = asyncio.Queue() - self._queue_rx_luba_cmd = asyncio.Queue() - self._queue_tx_conf = asyncio.Queue() + self._queue_rx_raw_dali: asyncio.Queue[Any] = asyncio.Queue() + self._queue_rx_luba_cmd: asyncio.Queue[Any] = asyncio.Queue() + self._queue_tx_conf: asyncio.Queue[Any] = asyncio.Queue() self._prev_rx_enable_dt = 0 self._prev_tx_enable_dt = 0 self._tx_lock = asyncio.Lock() - self._rx_state = None + self._rx_state = self.ReadState.WAIT_START self.rx_idle = asyncio.Event() - self._buffer = None - self._rx_expected_len = None - self._rx_received_len = None + self._buffer: list[int] = [] + self._rx_expected_len: Optional[int] = None + self._rx_received_len: int = 0 self._connected = asyncio.Event() self._dev_info: Optional[DriverLubaRs232.LubaDeviceInfo] = None self._dev_inst_map: Optional[DeviceInstanceTypeMapper] = None @@ -528,12 +526,20 @@ def __init__(self) -> None: self.reset() + @property + def _tx(self) -> serialx.SerialTransport: + """The connected serial transport, or raise if not connected.""" + if self.transport is None: + raise IOError("serial transport not connected") + return self.transport + @property def rx_state(self) -> ReadState: + assert self._rx_state is not None return self._rx_state @rx_state.setter - def rx_state(self, state: ReadState): + def rx_state(self, state: ReadState) -> None: if not isinstance(state, self.ReadState): raise TypeError( f"rx_state must be a ReadState enum, not {type(state)}" @@ -550,19 +556,19 @@ def dev_inst_map(self) -> Optional[DeviceInstanceTypeMapper]: return self._dev_inst_map @dev_inst_map.setter - def dev_inst_map(self, value: DeviceInstanceTypeMapper): + def dev_inst_map(self, value: DeviceInstanceTypeMapper) -> None: self._dev_inst_map = value @property def queue_rx_dali(self) -> DistributorQueue: return self._queue_rx_dali - def reset(self): + def reset(self) -> None: """ Returns the state machine to "WAIT_START" """ self.rx_state = self.ReadState.WAIT_START - self._buffer = [None] * self.MAX_LEN + self._buffer = [0] * self.MAX_LEN self._rx_expected_len = None self._rx_received_len = 0 @@ -573,7 +579,7 @@ async def wait_dali_raw_response(self) -> int: :return: A received DALI frame, as an int """ - return await self._queue_rx_raw_dali.get() + return cast(int, await self._queue_rx_raw_dali.get()) def reset_dali_response(self) -> None: """Drop any DALI responses left buffered from a previous exchange. @@ -605,7 +611,7 @@ def _insert_checksum(in_ints: list[int]) -> None: async def send_dali_command( self, tx: command.Command, priority: int = 1 - ) -> None: + ) -> int: """ Sends a variable length DALI command (16 or 24 bits), waiting until the LUBA device confirms it has sent the message before @@ -618,7 +624,7 @@ async def send_dali_command( # data before we send await self.rx_idle.wait() - dali_ints = tx.frame.as_byte_sequence + dali_ints = list(tx.frame.as_byte_sequence) if not len(dali_ints) in (2, 3): raise ValueError( f"Only works with 16 or 24 bit messages, not {8*len(dali_ints)}" @@ -636,7 +642,7 @@ async def send_dali_command( dali_ints[1], # Data, big-endian 0 if len(dali_ints) == 2 else dali_ints[2], # Data, big-endian 0, # Pad out to four bytes, to fit LUBA frame - None, # Checksum + 0, # Checksum ] # Fill in the checksum self._insert_checksum(tx_ints) @@ -648,7 +654,7 @@ async def send_dali_command( _LOG.trace( f"LUBA frame to send: {[f'0x{data:02x}' for data in tx_ints]}" ) - self.transport.write(bytearray(tx_ints)) + self._tx.write(bytearray(tx_ints)) # Wait for the LUBA device to respond, considering whether the # message is sent twice or not @@ -680,7 +686,7 @@ async def send_dali_command( confirm_count -= 1 # Release the lock - return confirm.tx_id + return cast(int, confirm.tx_id) async def send_device_info_query(self) -> None: """ @@ -694,7 +700,7 @@ async def send_device_info_query(self) -> None: DriverLubaRs232.LubaCmd.QUERY_DEVICE_INFO_CMD.value, # LUBA Command 1, # Length 0, # Request set "0" information - None, # Checksum + 0, # Checksum ] # Fill in the checksum self._insert_checksum(tx_ints) @@ -702,7 +708,7 @@ async def send_device_info_query(self) -> None: _LOG.trace( f"LUBA frame to send: {[f'0x{data:02x}' for data in tx_ints]}" ) - self.transport.write(bytearray(tx_ints)) + self._tx.write(bytearray(tx_ints)) # Wait for the LUBA device to respond dev_info = await asyncio.wait_for( @@ -729,14 +735,14 @@ async def send_device_descriptor_query( 0x59, # ASCII 'Y' DriverLubaRs232.LubaCmd.QUERY_DEVICE_DESCRIPTOR_CMD.value, 0, # Length, this command carries no data - None, # Checksum + 0, # 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)) + self._tx.write(bytearray(tx_ints)) descriptor = await asyncio.wait_for( self._queue_rx_luba_cmd.get(), @@ -768,14 +774,14 @@ async def send_read_status( DriverLubaRs232.LubaCmd.READ_STATUS_CMD.value, 1, # Length 0, # Line 0 (devices with a single DALI line) - None, # Checksum + 0, # 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)) + self._tx.write(bytearray(tx_ints)) line_status = await asyncio.wait_for( self._queue_rx_luba_cmd.get(), @@ -823,7 +829,7 @@ async def send_device_settings(self) -> None: mode_settings, event_settings, hardware_settings, - None, # Checksum + 0, # Checksum ] # Fill in the checksum self._insert_checksum(tx_ints) @@ -831,7 +837,7 @@ async def send_device_settings(self) -> None: _LOG.trace( f"LUBA frame to send: {[f'0x{data:02x}' for data in tx_ints]}" ) - self.transport.write(bytearray(tx_ints)) + self._tx.write(bytearray(tx_ints)) # Wait for the LUBA device to respond dev_settings = await asyncio.wait_for( @@ -984,7 +990,7 @@ def _process_byte(self, rx_int: int) -> None: else: raise RuntimeError(f"Invalid state: {self._rx_state}") - def _process_luba_event(self, received_data: tuple): + def _process_luba_event(self, received_data: Sequence[int]) -> None: """ Handle a LUBA 'event' message, typically these are received when a DALI frame was observed on the bus by the LUBA device @@ -1022,7 +1028,7 @@ def _process_luba_event(self, received_data: tuple): # Decode the command, for logging and debugging try: dali_command = command.Command.from_frame( - frame.Frame(bits=8 * len(tx_dali), data=tx_dali), + frame.ForwardFrame(bits=8 * len(tx_dali), data=tx_dali), devicetype=self._prev_tx_enable_dt, dev_inst_map=self._dev_inst_map, ) @@ -1090,7 +1096,7 @@ def _process_luba_event(self, received_data: tuple): else: # A 16 or 24-bit frame is an intercepted DALI command, # it can be deciphered into a Command object - dali_frame = frame.Frame( + dali_frame = frame.ForwardFrame( bits=8 * len(rx_dali), data=rx_dali ) try: @@ -1114,7 +1120,7 @@ def _process_luba_event(self, received_data: tuple): _LOG.debug(f"Adding DALI command to queue: {dali_command}") self._queue_rx_dali.distribute(dali_command) - def _process_luba_response_dali_frame_to_tx(self, received_data: tuple): + def _process_luba_response_dali_frame_to_tx(self, received_data: Sequence[int]) -> None: """ Handle a LUBA 'ADD DALI FRAME TO TX BUFFER' response message """ @@ -1149,7 +1155,7 @@ def _process_luba_response_dali_frame_to_tx(self, received_data: tuple): f"Invalid LUBA response length: {payload_length}" ) - def _process_luba_response_device_info(self, received_data: tuple): + def _process_luba_response_device_info(self, received_data: Sequence[int]) -> None: """ Handle a received "QUERY DEVICE INFO" response message """ @@ -1206,8 +1212,8 @@ def _process_luba_response_device_info(self, received_data: tuple): self._queue_rx_luba_cmd.put_nowait(info) def _process_luba_response_device_descriptor( - self, received_data: tuple - ): + self, received_data: Sequence[int] + ) -> None: """ Handle a received "QUERY DEVICE DESCRIPTOR" response message """ @@ -1234,7 +1240,7 @@ def _process_luba_response_device_descriptor( _LOG.info(f"Received device descriptor: {descriptor}") self._queue_rx_luba_cmd.put_nowait(descriptor) - def _process_luba_response_status(self, received_data: tuple): + def _process_luba_response_status(self, received_data: Sequence[int]) -> None: """ Handle a received "READ STATUS" response message """ @@ -1262,7 +1268,7 @@ def _process_luba_response_status(self, received_data: tuple): _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): + def _process_luba_response_settings(self, received_data: Sequence[int]) -> None: """ Handle a received "READ / WRITE SETTINGS" response message """ @@ -1280,17 +1286,17 @@ def _process_luba_response_settings(self, received_data: tuple): _LOG.info(f"Received device settings: {settings}") self._queue_rx_luba_cmd.put_nowait(settings) - def connection_made(self, transport): - self.transport = transport + def connection_made(self, transport: asyncio.BaseTransport) -> None: + self.transport = cast(serialx.SerialTransport, transport) _LOG.info(f"Serial port opened: {transport}") self._connected.set() - def data_received(self, data): - _LOG.trace(f"Serial data received: {data}") + def data_received(self, data: bytes) -> None: + _LOG.trace(f"Serial data received: {data!r}") for rx in data: self._process_byte(rx) - def connection_lost(self, exc): + def connection_lost(self, exc: Optional[Exception]) -> None: _LOG.info("Serial port closed") self._connected.clear() @@ -1317,6 +1323,13 @@ def __init__( self._transport: Optional[serialx.SerialTransport] = None self._protocol: Optional[DriverLubaRs232.LubaProtocol] = None + @property + def _proto(self) -> DriverLubaRs232.LubaProtocol: + """The connected protocol, or raise if the driver is not connected.""" + if self._protocol is None: + raise IOError("DALI driver not connected") + return self._protocol + async def connect(self, *, scan_dev_inst: bool = False) -> None: if self.is_connected: _LOG.warning( @@ -1325,18 +1338,17 @@ async def connect(self, *, scan_dev_inst: bool = False) -> None: return # TODO: Add failure/retry handling - ( - self._transport, - self._protocol, - ) = await serialx.create_serial_connection( + transport, protocol = await serialx.create_serial_connection( loop=asyncio.get_event_loop(), protocol_factory=DriverLubaRs232.LubaProtocol, url=self.serial_path, baudrate=38400, ) + self._transport = transport + self._protocol = cast(DriverLubaRs232.LubaProtocol, protocol) try: await asyncio.wait_for( - self._protocol.connected.wait(), + self._proto.connected.wait(), timeout=DriverLubaRs232.timeout_connect, ) except asyncio.exceptions.TimeoutError as exc: @@ -1345,9 +1357,9 @@ async def connect(self, *, scan_dev_inst: bool = False) -> None: 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() + await self._proto.send_device_info_query() + self._proto.bus_power = self.bus_power + await self._proto.send_device_settings() break except (asyncio.TimeoutError, TimeoutError): if attempt >= DriverLubaRs232.handshake_attempts: @@ -1359,8 +1371,8 @@ async def connect(self, *, scan_dev_inst: bool = False) -> None: _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._proto.reset_luba_response() + self._proto.dev_inst_map = self.dev_inst_map self._connected.set() @@ -1385,7 +1397,7 @@ async def _wait_for_bus_power(self) -> None: loop = asyncio.get_event_loop() deadline = loop.time() + DriverLubaRs232.timeout_bus_power while loop.time() < deadline: - status = await self._protocol.send_read_status() + status = await self._proto.send_read_status() if status is not None and not status.bus_voltage_error: return await asyncio.sleep(0.1) @@ -1397,13 +1409,13 @@ async def query_device_descriptor( """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() + return await self._proto.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() + return await self._proto.send_read_status() async def _send_frame( self, cmd: command.Command, *, priority: int = 1 @@ -1417,28 +1429,29 @@ async def _send_frame( # Make sure the received command buffer is empty, so that an # unexpected response can't accidentally be used - self._protocol.reset_dali_response() - await self._protocol.send_dali_command(cmd, priority=priority) - if cmd.is_query: + self._proto.reset_dali_response() + await self._proto.send_dali_command(cmd, priority=priority) + response_type = cmd.response + if response_type is not 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 = cmd.response(None) + response = response_type(None) while True: try: raw_rsp = await asyncio.wait_for( - self._protocol.wait_dali_raw_response(), + self._proto.wait_dali_raw_response(), timeout=DriverLubaRs232.timeout_rx, ) except asyncio.exceptions.TimeoutError: _LOG.debug(f"DALI response timeout, from message: {cmd}") break if raw_rsp is _DALI_FRAMING_ERROR: - response = cmd.response(frame.BackwardFrameError(255)) + response = response_type(frame.BackwardFrameError(255)) _LOG.debug(f"DALI framing error, from message: {cmd}") break if isinstance(raw_rsp, int): - response = cmd.response(frame.BackwardFrame(raw_rsp)) + response = response_type(frame.BackwardFrame(raw_rsp)) _LOG.debug(f"DALI response received: {raw_rsp}") break else: @@ -1452,7 +1465,7 @@ async def _send_frame( return response def new_dali_rx_queue(self) -> DistributorQueue: - return DistributorQueue(self._protocol.queue_rx_dali) + return DistributorQueue(self._proto.queue_rx_dali) @@ -1546,19 +1559,19 @@ class SCIRS232MsgTxConf(NamedTuple): def __init__(self) -> None: super().__init__() - self.transport = None + self.transport: Optional[serialx.SerialTransport] = None self._queue_rx_dali = DistributorQueue() - self._queue_rx_raw_dali = asyncio.Queue() - self._queue_rx_info = asyncio.Queue() + self._queue_rx_raw_dali: asyncio.Queue[Any] = asyncio.Queue() + self._queue_rx_info: asyncio.Queue[Any] = asyncio.Queue() self._prev_rx_enable_dt = 0 self._prev_tx_enable_dt = 0 self._tx_lock = asyncio.Lock() - self._rx_state = None + self._rx_state = self.ReadState.WAIT_STATUS self.rx_idle = asyncio.Event() - self._buffer = None - self._rx_expected_len = None - self._rx_received_len = None + self._buffer: list[int] = [] + self._rx_expected_len: Optional[int] = None + self._rx_received_len: int = 0 self._connected = asyncio.Event() self._dev_info: Optional[DriverSCIRS232.SCIRS232DeviceReply] = None self._dev_inst_map: Optional[DeviceInstanceTypeMapper] = None @@ -1569,12 +1582,20 @@ def __init__(self) -> None: self.reset() + @property + def _tx(self) -> serialx.SerialTransport: + """The connected serial transport, or raise if not connected.""" + if self.transport is None: + raise IOError("serial transport not connected") + return self.transport + @property def rx_state(self) -> ReadState: + assert self._rx_state is not None return self._rx_state @rx_state.setter - def rx_state(self, state: ReadState): + def rx_state(self, state: ReadState) -> None: if not isinstance(state, self.ReadState): raise TypeError( f"rx_state must be a ReadState enum, not {type(state)}" @@ -1591,19 +1612,19 @@ def dev_inst_map(self) -> Optional[DeviceInstanceTypeMapper]: return self._dev_inst_map @dev_inst_map.setter - def dev_inst_map(self, value: DeviceInstanceTypeMapper): + def dev_inst_map(self, value: DeviceInstanceTypeMapper) -> None: self._dev_inst_map = value @property def queue_rx_dali(self) -> DistributorQueue: return self._queue_rx_dali - def reset(self): + def reset(self) -> None: """ Returns the state machine to "WAIT_STATUS" """ self.rx_state = self.ReadState.WAIT_STATUS - self._buffer = [None] * self.MAX_LEN + self._buffer = [0] * self.MAX_LEN self._rx_expected_len = None self._rx_received_len = 0 @@ -1614,7 +1635,7 @@ async def wait_dali_raw_response(self) -> int: :return: A received DALI frame, as an int """ - return await self._queue_rx_raw_dali.get() + return cast(int, await self._queue_rx_raw_dali.get()) def reset_dali_response(self) -> None: """Drop any DALI frames left buffered from a previous exchange. @@ -1656,7 +1677,7 @@ async def send_dali_command(self, tx: command.Command) -> None: # data before we send await self.rx_idle.wait() - dali_ints = tx.frame.as_byte_sequence + dali_ints = list(tx.frame.as_byte_sequence) if not len(dali_ints) in (1, 2, 3): raise ValueError( f"Only works with 8, 16 or 24 bit messages, not {8*len(dali_ints)}" @@ -1675,7 +1696,7 @@ async def send_dali_command(self, tx: command.Command) -> None: dali_ints[0], 0 if len(dali_ints) < 2 else dali_ints[1], 0 if len(dali_ints) < 3 else dali_ints[2], - None, # Checksum + 0, # Checksum ] # Fill in the checksum self._insert_checksum(tx_ints) @@ -1687,7 +1708,7 @@ async def send_dali_command(self, tx: command.Command) -> None: _LOG.trace( f"SCI RS232 frame to send: {[f'0x{data:02x}' for data in tx_ints]}" ) - self.transport.write(bytearray(tx_ints)) + self._tx.write(bytearray(tx_ints)) confirm = await asyncio.wait_for( self._queue_rx_info.get(), @@ -1711,7 +1732,7 @@ async def send_device_info_query(self) -> None: 0, 0, 0, - None, # Checksum + 0, # Checksum ] # Fill in the checksum self._insert_checksum(tx_ints) @@ -1724,7 +1745,7 @@ async def send_device_info_query(self) -> None: _LOG.trace( f"SCI RS232 frame to send: {[f'0x{data:02x}' for data in tx_ints]}" ) - self.transport.write(bytearray(tx_ints)) + self._tx.write(bytearray(tx_ints)) # Wait for the SCI RS232 device to respond dev_info = await asyncio.wait_for( @@ -1833,12 +1854,12 @@ def _process_byte(self, rx_int: int) -> None: else: raise RuntimeError(f"Invalid state: {self._rx_state}") - def _process_system_message(self, data : int): + def _process_system_message(self, data: int) -> None: device_id_info = DriverSCIRS232.SCIRS232DeviceReply( id=(data & 0xf0) >> 4,code=data&0xf) self._queue_rx_info.put_nowait(device_id_info) - def _process_error(self, data : tuple): + def _process_error(self, data: Sequence[int]) -> None: try: error_type = DriverSCIRS232.SCIRS232Protocol.ErrorType(data[3]) except ValueError: @@ -1863,7 +1884,7 @@ def _process_error(self, data : tuple): _LOG.error(f"SCI RS232 reports {error_str} error ({error_type})") self._process_system_message(data[0]) - def _process_dali_frame(self, received_data: tuple): + def _process_dali_frame(self, received_data: Sequence[int]) -> None: """ Handle a DALI 'event' message, typically these are received when a DALI frame was observed on the bus by the SCI RS232 device @@ -1884,7 +1905,7 @@ def _process_dali_frame(self, received_data: tuple): else: # A 16 or 24-bit frame is an intercepted DALI command, # it can be deciphered into a Command object - dali_frame = frame.Frame( + dali_frame = frame.ForwardFrame( bits=8 * len(received_data), data=received_data ) try: @@ -1908,17 +1929,17 @@ def _process_dali_frame(self, received_data: tuple): _LOG.debug(f"Adding DALI command to queue: {dali_command}") self._queue_rx_dali.distribute(dali_command) - def connection_made(self, transport): - self.transport = transport + def connection_made(self, transport: asyncio.BaseTransport) -> None: + self.transport = cast(serialx.SerialTransport, transport) _LOG.info(f"Serial port opened: {transport}") self._connected.set() - def data_received(self, data): - _LOG.trace(f"Serial data received: {data}") + def data_received(self, data: bytes) -> None: + _LOG.trace(f"Serial data received: {data!r}") for rx in data: self._process_byte(rx) - def connection_lost(self, exc): + def connection_lost(self, exc: Optional[Exception]) -> None: _LOG.info("Serial port closed") self._connected.clear() @@ -1943,6 +1964,13 @@ def __init__( self._transport: Optional[serialx.SerialTransport] = None self._protocol: Optional[DriverSCIRS232.SCIRS232Protocol] = None + @property + def _proto(self) -> DriverSCIRS232.SCIRS232Protocol: + """The connected protocol, or raise if the driver is not connected.""" + if self._protocol is None: + raise IOError("DALI driver not connected") + return self._protocol + async def connect(self, *, scan_dev_inst: bool = False) -> None: if self.is_connected: _LOG.warning( @@ -1955,27 +1983,26 @@ async def connect(self, *, scan_dev_inst: bool = False) -> None: ) # TODO: Add failure/retry handling - ( - self._transport, - self._protocol, - ) = await serialx.create_serial_connection( + transport, protocol = await serialx.create_serial_connection( loop=asyncio.get_event_loop(), protocol_factory=DriverSCIRS232.SCIRS232Protocol, url=self.serial_path, baudrate=38400, ) + self._transport = transport + self._protocol = cast(DriverSCIRS232.SCIRS232Protocol, protocol) try: await asyncio.wait_for( - self._protocol._connected.wait(), + self._proto._connected.wait(), timeout=DriverSCIRS232.timeout_connect, ) except asyncio.exceptions.TimeoutError as exc: _LOG.critical(f"Timeout waiting for driver to connect: {exc}") raise - await self._protocol.send_device_info_query() - self._protocol.dev_inst_map = self.dev_inst_map + await self._proto.send_device_info_query() + self._proto.dev_inst_map = self.dev_inst_map self._connected.set() @@ -2003,25 +2030,26 @@ async def _send_frame( # Make sure the received command buffer is empty, so that an # unexpected response can't accidentally be used - self._protocol.reset_dali_response() - await self._protocol.send_dali_command(cmd) - if cmd.is_query: + self._proto.reset_dali_response() + await self._proto.send_dali_command(cmd) + response_type = cmd.response + if response_type is not 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 = cmd.response(None) + response = response_type(None) while True: try: raw_rsp = await asyncio.wait_for( - self._protocol._queue_rx_raw_dali.get(), - #self._protocol.wait_dali_raw_response(), + self._proto._queue_rx_raw_dali.get(), + #self._proto.wait_dali_raw_response(), timeout=DriverSCIRS232.timeout_rx, ) except asyncio.exceptions.TimeoutError: _LOG.debug(f"DALI response timeout, from message: {cmd}") break if isinstance(raw_rsp, int): - response = cmd.response(frame.BackwardFrame(raw_rsp)) + response = response_type(frame.BackwardFrame(raw_rsp)) _LOG.debug(f"DALI response received: {raw_rsp}") break else: @@ -2035,4 +2063,4 @@ async def _send_frame( return response def new_dali_rx_queue(self) -> DistributorQueue: - return DistributorQueue(self._protocol.queue_rx_dali) + return DistributorQueue(self._proto.queue_rx_dali) diff --git a/dali/driver/trace_logging.py b/dali/driver/trace_logging.py index 23ee2e4b..abecbf82 100644 --- a/dali/driver/trace_logging.py +++ b/dali/driver/trace_logging.py @@ -16,12 +16,24 @@ If not, see . """ import logging +from typing import Any, cast -_LOG = logging.getLogger(f"dali") +_LOG = logging.getLogger("dali") _logging_trace_created = False -def add_logging_level(level_name: str, level_num: int): +class TraceLogger(logging.Logger): + """A logger exposing the extra TRACE level installed by this module.""" + + def trace(self, msg: object, *args: object, **kwargs: object) -> None: ... + + +def get_logger(name: str) -> TraceLogger: + """Return a logger typed as exposing the TRACE level.""" + return cast(TraceLogger, logging.getLogger(name)) + + +def add_logging_level(level_name: str, level_num: int) -> None: """ Original source: https://stackoverflow.com/a/35804945 @@ -46,11 +58,13 @@ def add_logging_level(level_name: str, level_num: int): # This method was inspired by the answers to Stack Overflow post # http://stackoverflow.com/q/2183233/2988730, especially # http://stackoverflow.com/a/13638084/2988730 - def logForLevel(self, message, *args, **kwargs): + def logForLevel( + self: logging.Logger, message: object, *args: Any, **kwargs: Any + ) -> None: if self.isEnabledFor(level_num): self._log(level_num, message, args, **kwargs) - def logToRoot(message, *args, **kwargs): + def logToRoot(message: object, *args: Any, **kwargs: Any) -> None: logging.log(level_num, message, *args, **kwargs) logging.addLevelName(level_num, level_name) diff --git a/dali/exceptions.py b/dali/exceptions.py index fd06e535..47bd8af5 100644 --- a/dali/exceptions.py +++ b/dali/exceptions.py @@ -50,7 +50,7 @@ class DALISequenceError(DALIError): class ProgramShortAddressFailure(DALIError): """A device did not accept programming of its short address.""" - def __init__(self, address): + def __init__(self, address: int) -> None: self.address = address @@ -125,7 +125,7 @@ class TransmissionError(DriverError): ``error_code`` is the interface-specific reason for the rejection. """ - def __init__(self, error_code): + def __init__(self, error_code: object) -> None: self.error_code = error_code super().__init__( f"Frame transmission rejected, error code {error_code}" diff --git a/dali/frame.py b/dali/frame.py index 82baa7e2..c3e94e67 100644 --- a/dali/frame.py +++ b/dali/frame.py @@ -1,3 +1,8 @@ +from __future__ import annotations + +from collections.abc import Iterable + + class Frame: """A DALI frame. @@ -7,7 +12,7 @@ class Frame: Instances of this object are mutable. """ - def __init__(self, bits, data=0): + def __init__(self, bits: int, data: int | Iterable[int] = 0) -> None: """Initialise a Frame with the supplied number of data bits. :parameter bits: the number of data bits in the Frame @@ -34,26 +39,30 @@ def __init__(self, bits, data=0): self._error = False @property - def error(self): + def error(self) -> bool: """Frame was received with a framing error.""" return self._error - def __len__(self): + def __len__(self) -> int: return self._bits - def __eq__(self, other): + def __eq__(self, other: object) -> bool: + if not isinstance(other, Frame): + return False try: return self._bits == other._bits and self._data == other._data except Exception: return False - def __ne__(self, other): + def __ne__(self, other: object) -> bool: + if not isinstance(other, Frame): + return True try: return self._bits != other._bits or self._data != other._data except Exception: return True - def _readslice(self, key): + def _readslice(self, key: slice) -> tuple[int, int]: """Check that a slice is valid, return indices The slice must have indices that are integers. The indices @@ -71,10 +80,10 @@ def _readslice(self, key): raise IndexError("slice index out of range") return hi, lo - def __getitem__(self, key): + def __getitem__(self, key: slice | int) -> int: """Read a bit or group of bits from the frame - If the key is an integer, return that bit as True or False or + If the key is an integer, return that bit as 1 or 0 or raise IndexError if the key is out of bounds. If the key is a slice, return that slice as an integer or @@ -93,7 +102,7 @@ def __getitem__(self, key): return (self._data & (1 << key)) != 0 raise TypeError - def __setitem__(self, key, value): + def __setitem__(self, key: slice | int, value: int) -> None: """Write a bit or a group of bits to the frame If the key is an integer, set that bit to the truth value of @@ -127,27 +136,26 @@ def __setitem__(self, key, value): else: raise TypeError - def __contains__(self, item): + def __contains__(self, item: object) -> bool: if item is True: return self._data != 0 if item is False: return self._data != (1 << self._bits) - 1 return False - def __add__(self, other): - try: - return Frame(self._bits + other._bits, - self._data << other._bits | other._data) - except Exception: + def __add__(self, other: object) -> Frame: + if not isinstance(other, Frame): raise TypeError("Frame can only be added to another Frame") + return Frame(self._bits + other._bits, + self._data << other._bits | other._data) @property - def as_integer(self): + def as_integer(self) -> int: """The contents of the frame represented as an integer.""" return self._data @property - def as_byte_sequence(self): + def as_byte_sequence(self) -> Iterable[int]: """The contents of the frame represented as a sequence. Returns a sequence of integers each in the range 0..255 @@ -159,7 +167,7 @@ def as_byte_sequence(self): return list(self.pack) @property - def pack(self): + def pack(self) -> bytes: """The contents of the frame represented as a byte string. If the frame is not an exact multiple of 8 bits long, the @@ -169,7 +177,7 @@ def pack(self): (len(self) // 8) + (1 if len(self) % 8 else 0), 'big') - def pack_len(self, l): + def pack_len(self, l: int) -> bytes: """The contents of the frame represented as a fixed length byte string. The least significant bit of the frame is aligned to the end @@ -181,7 +189,7 @@ def pack_len(self, l): """ return self._data.to_bytes(l, 'big') - def __str__(self): + def __str__(self) -> str: return "{}({},{})".format(self.__class__.__name__, len(self), self.as_byte_sequence) @@ -198,11 +206,11 @@ class ForwardFrame(Frame): """ @property - def is_reserved(self): + def is_reserved(self) -> bool: return len(self) == 20 or len(self) == 32 @property - def is_proprietary(self): + def is_proprietary(self) -> bool: return len(self) not in (16, 20, 24, 32) @@ -218,10 +226,10 @@ class BackwardFrame(Frame): BackwardFrameError instead. """ - def __init__(self, data): + def __init__(self, data: int | Iterable[int]) -> None: super().__init__(8, data) - def __str__(self): + def __str__(self) -> str: return "{}({})".format(self.__class__.__name__, self._data) @@ -234,6 +242,6 @@ class BackwardFrameError(BackwardFrame): interpreted as "more than one device responded Yes". """ - def __init__(self, data): + def __init__(self, data: int | Iterable[int]) -> None: super().__init__(data) self._error = True diff --git a/dali/gear/colour.py b/dali/gear/colour.py index 2b3b18a3..2905498a 100644 --- a/dali/gear/colour.py +++ b/dali/gear/colour.py @@ -4,9 +4,10 @@ """ from __future__ import annotations +from typing import Optional from enum import IntEnum -from dali import command +from dali import command, frame from dali.gear.general import _StandardCommand @@ -299,24 +300,22 @@ class QueryColourTypeFeaturesResponse(command.BitmapResponse): ] @property - def primary_n(self) -> int: + def primary_n(self) -> Optional[int]: """ Returns the number of primaries supported by the control gear """ - count = self.primary_N_bit_2 << 2 - count += self.primary_N_bit_1 << 1 - count += self.primary_N_bit_0 - return count + if not self.raw_value or self.raw_value.error: + return None + return self.raw_value[4:2] @property - def RGBWAF_channels(self) -> int: + def RGBWAF_channels(self) -> Optional[int]: """ Returns the number of RGBWAF channels supported by the control gear """ - count = self.RGBWAF_channels_bit_2 << 2 - count += self.RGBWAF_channels_bit_1 << 1 - count += self.RGBWAF_channels_bit_0 - return count + if not self.raw_value or self.raw_value.error: + return None + return self.raw_value[7:5] class QueryColourTypeFeatures(_ColourCommand): @@ -359,8 +358,11 @@ def control_type(self) -> str: * colour control * normalised colour control """ - count = self.control_type_bit_1 << 1 - count += self.control_type_bit_0 + if not self.raw_value or self.raw_value.error: + # XXX arguably we should be raising an exception or returning + # None here? + return "(error)" + count = self.raw_value[7:6] if count == 0: return "channel control" elif count == 1: @@ -402,7 +404,7 @@ class QueryAssignedColourResponse(command.EnumResponse): enumerator = AssignedColour @property - def value(self): + def value(self) -> Optional[frame.BackwardFrame | int | str | bool | IntEnum]: if self.raw_value is None: return None _value = self.raw_value.as_integer diff --git a/dali/gear/converter.py b/dali/gear/converter.py index 8bb5368a..885af883 100644 --- a/dali/gear/converter.py +++ b/dali/gear/converter.py @@ -98,7 +98,7 @@ class QueryDimmingCurve(_ConversionCommand): class OutputLevelResponse(command.NumericResponse): - def __str__(self): + def __str__(self) -> str: if isinstance(self.value, int): if self.value == 254: return "10.16V or more" @@ -106,7 +106,7 @@ def __str__(self): return "unknown" else: return f"{self.value * 0.04} V" - return self.value + return str(self.value) class QueryOutputLevel(_ConversionCommand): diff --git a/dali/gear/emergency.py b/dali/gear/emergency.py index 615d06de..f59a439d 100644 --- a/dali/gear/emergency.py +++ b/dali/gear/emergency.py @@ -293,16 +293,19 @@ class QueryEmergencyModeResponse(command.BitmapResponse): "hardwired inhibit active", "hardwired switch on"] @property - def mode(self): + def mode(self) -> str: """Operating mode of the emergency control gear. Only one of bits 0-5 should be set at once, but we support returning a sensible response even when multiple bits are set. """ - v = self._value[5:0] - l = [] + if not self.raw_value or self.raw_value.error: + return "(error)" + v = self.raw_value[5:0] + l: list[str] = [] for b in self.bits: if v & 0x01: - l.append(b) + if b: + l.append(b) v = (v >> 1) return ",".join(l) diff --git a/dali/gear/general.py b/dali/gear/general.py index 99a728c3..8a8fe21b 100644 --- a/dali/gear/general.py +++ b/dali/gear/general.py @@ -7,6 +7,9 @@ or DTR0, the abbreviation has been kept in capitals. """ +from __future__ import annotations + +from typing import Optional from dali import command from dali import address from dali import frame @@ -15,14 +18,17 @@ class _GearCommand(command.Command): _framesize = 16 - _gearcommands = [] + _gearcommands: list[type[_GearCommand]] = [] @classmethod - def _register_subclass(cls, subclass): + def _register_subclass(cls, subclass: type[command.Command]) -> None: + assert issubclass(subclass, _GearCommand) cls._gearcommands.append(subclass) @classmethod - def from_frame(cls, f, devicetype=0, dev_inst_map=None): + def from_frame(cls, f: frame.ForwardFrame, devicetype: int = 0, + dev_inst_map: Optional[command.DITM] = None + ) -> Optional[_GearCommand]: for gc in cls._gearcommands: r = gc.from_frame( f, devicetype=devicetype, dev_inst_map=dev_inst_map @@ -31,13 +37,30 @@ def from_frame(cls, f, devicetype=0, dev_inst_map=None): return r return UnknownGearCommand(f) + @staticmethod + def _check_destination( + destination: int | address.GearAddress) -> address.GearAddress: + """Check that a valid destination has been specified. + + destination can be a dali.address.Address object with + add_to_frame method, or an integer which will be wrapped in a + dali.address.Address object. + """ + if isinstance(destination, int): + destination = address.GearShort(destination) + if isinstance(destination, address.GearAddress): + return destination + raise ValueError('destination must be an integer or ' + 'dali.address.GearAddress object') + class UnknownGearCommand(_GearCommand): """An unknown command addressed to control gear. """ @classmethod - def from_frame(cls, f, devicetype=0, dev_inst_map=None): - return + def from_frame(cls, f: frame.ForwardFrame, devicetype: int = 0, + dev_inst_map: Optional[command.DITM] = None) -> None: + return None ############################################################################### @@ -58,10 +81,12 @@ class _StandardCommand(_GearCommand): 4-bit parameter is to be used as the least significant 4 bits of the opcode byte. """ - _cmdval = None + _cmdval = -1 _hasparam = False + param: int - def __init__(self, destination, *args): + def __init__(self, destination: address.GearAddress | int, + *args: int) -> None: if self._cmdval is None: raise NotImplementedError @@ -96,29 +121,36 @@ def __init__(self, destination, *args): super().__init__(f) # (devicetype, opcode) -> commandclass - _opcodes = {} + _opcodes: dict[tuple[int, int], type[_StandardCommand]] = {} @classmethod - def _register_subclass(cls, subclass): + def _register_subclass(cls, subclass: type[command.Command]) -> None: + assert issubclass(subclass, _StandardCommand) if subclass.__name__[0] == '_': return + assert subclass._cmdval != -1 if subclass._hasparam: for x in range(0, 0x10): - cls._opcodes[(subclass.devicetype, subclass._cmdval + x)] = subclass + cls._opcodes[(subclass.devicetype, subclass._cmdval + x)] \ + = subclass else: cls._opcodes[(subclass.devicetype, subclass._cmdval)] = subclass @classmethod - def from_frame(cls, f, devicetype=0, dev_inst_map=None): + def from_frame(cls, f: frame.ForwardFrame, devicetype: int = 0, + dev_inst_map: Optional[command.DITM] = None + ) -> Optional[_GearCommand]: if not f[8]: # It's a direct arc power control command - return + return None addr = address.from_frame(f) if addr is None: # It's probably a _SpecialCommand - return + return None + + assert isinstance(addr, address.GearAddress) opcode = f[7:0] @@ -131,7 +163,7 @@ def from_frame(cls, f, devicetype=0, dev_inst_map=None): return cc(addr) - def __str__(self): + def __str__(self) -> str: if self._hasparam: return "%s(%s,%s)" % ( self.__class__.__name__, @@ -155,7 +187,8 @@ class DAPC(_GearCommand): be respected. """ - def __init__(self, destination, power): + def __init__(self, destination: address.GearAddress | int, + power: int | str) -> None: if power == "OFF": power = 0 @@ -176,15 +209,19 @@ def __init__(self, destination, power): super().__init__(f) @classmethod - def from_frame(cls, f, devicetype=0, dev_inst_map=None): + def from_frame(cls, f: frame.ForwardFrame, devicetype: int = 0, + dev_inst_map: Optional[command.DITM] = None + ) -> Optional[_GearCommand]: if f[8]: - return + return None addr = address.from_frame(f) if addr is None: - return + return None + assert isinstance(addr, address.GearAddress) return cls(addr, f[7:0]) - def __str__(self): + def __str__(self) -> str: + power: str | int if self.power == 0: power = "OFF" elif self.power == 255: @@ -585,12 +622,12 @@ class QueryStatusResponse(command.BitmapResponse): "power failure"] @property - def error(self): + def error(self) -> bool: """Is the ballast in any kind of error state? (Ballast not ok, lamp fail, missing short address) """ return self.ballast_status or self.lamp_failure \ - or self.missing_short_address + or self.missing_short_address or False class QueryStatus(_StandardCommand): @@ -685,9 +722,10 @@ class QueryDeviceTypeResponse(command.Response): 254: "none / end", 255: "multiple"} - def __str__(self): - if self.value and self.value.as_integer in self._types: - return self._types[self.value.as_integer] + def __str__(self) -> str: + if self.raw_value and not self.raw_value.error \ + and self.raw_value.as_integer in self._types: + return self._types[self.raw_value.as_integer] return "{}".format(self.value) @@ -827,16 +865,18 @@ class QuerySystemFailureLevel(_StandardCommand): class QueryFadeTimeAndRateResponse(command.NumericResponse): @property - def fade_time(self): - if self._value: - return self._value[7:4] + def fade_time(self) -> Optional[int]: + if self.raw_value and not self.raw_value.error: + return self.raw_value[7:4] + return None @property - def fade_rate(self): - if self._value: - return self._value[3:0] + def fade_rate(self) -> Optional[int]: + if self.raw_value and not self.raw_value.error: + return self.raw_value[3:0] + return None - def __str__(self): + def __str__(self) -> str: return "Fade time: {0}; Fade rate: {1}".format( self.fade_time, self.fade_rate @@ -978,7 +1018,7 @@ class QueryExtendedVersionNumberMixin: of QueryExtendedVersionNumber using this mixin. """ _cmdval = 0xff - response = command.NumericResponse + response: Optional[type[command.Response]] = command.NumericResponse class QueryExtendedVersionNumber(QueryExtendedVersionNumberMixin, @@ -1001,9 +1041,10 @@ class _SpecialCommand(_GearCommand): Special commands are broadcast and are received by all devices. """ _hasparam = False - _cmdval = None + _cmdval = -1 - def __init__(self, *args): + def __init__(self, *args: int) -> None: + param: int if self._hasparam: if len(args) != 1: raise TypeError( @@ -1021,19 +1062,23 @@ def __init__(self, *args): self.__class__.__name__, len(args) + 1)) param = 0 self.param = param + assert self._cmdval != -1 super().__init__(frame.ForwardFrame(16, (self._cmdval, self.param))) # dict of f[15:8] to cls - _opcodes = {} + _opcodes: dict[int, type[_SpecialCommand]] = {} @classmethod - def _register_subclass(cls, subclass): + def _register_subclass(cls, subclass: type[command.Command]) -> None: if subclass.__name__[0] == '_': return + assert issubclass(subclass, _SpecialCommand) cls._opcodes[subclass._cmdval] = subclass @classmethod - def from_frame(cls, f, devicetype=0, dev_inst_map=None): + def from_frame(cls, f: frame.ForwardFrame, devicetype: int = 0, + dev_inst_map: Optional[command.DITM] = None + ) -> Optional[_GearCommand]: opcode = f[15:8] if opcode == cls._cmdval: if cls._hasparam: @@ -1048,7 +1093,7 @@ def from_frame(cls, f, devicetype=0, dev_inst_map=None): return UnknownGearCommand(f) return cc.from_frame(f, devicetype=devicetype) - def __str__(self): + def __str__(self) -> str: if self._hasparam: return "{}({})".format(self.__class__.__name__, self.param) return "{}()".format(self.__class__.__name__) @@ -1057,30 +1102,34 @@ def __str__(self): class _ShortAddrSpecialCommand(_SpecialCommand): """A special command that has a short address as its parameter.""" _hasparam = True + address: int | str - def __init__(self, address): + def __init__(self, address: int | str) -> None: if address == "MASK": self.address = address + super().__init__(0xff) else: if not isinstance(address, int): raise ValueError("address must be an integer or 'MASK'") if address < 0 or address > 63: raise ValueError("address must be in the range 0..63") - self.address = address - data = 0xff if self.address == "MASK" else ((self.address << 1) | 1) - super().__init__(data) + self.address = address + super().__init__((self.address << 1) | 1) @classmethod - def from_frame(cls, f, devicetype=0, dev_inst_map=None): + def from_frame(cls, f: frame.ForwardFrame, devicetype: int = 0, + dev_inst_map: Optional[command.DITM] = None + ) -> Optional[_ShortAddrSpecialCommand]: if cls == _ShortAddrSpecialCommand: - return + return None if f[15:8] == cls._cmdval: if f[7:0] == 0xff: return cls("MASK") if f[7] is False and f[0] is True: return cls(f[6:1]) + return None - def __str__(self): + def __str__(self) -> str: return "{}({})".format(self.__class__.__name__, self.address) @@ -1117,7 +1166,8 @@ class Initialise(_SpecialCommand): _hasparam = True sendtwice = True - def __init__(self, broadcast=False, address=None): + def __init__(self, broadcast: bool = False, + address: Optional[int] = None) -> None: if broadcast and address is not None: raise ValueError("can't specify address when broadcasting") if address is not None: @@ -1136,7 +1186,9 @@ def __init__(self, broadcast=False, address=None): super().__init__(b) @classmethod - def from_frame(cls, f, devicetype=0, dev_inst_map=None): + def from_frame(cls, f: frame.ForwardFrame, devicetype: int = 0, + dev_inst_map: Optional[command.DITM] = None + ) -> Optional[_SpecialCommand]: if f[15:8] == cls._cmdval: if f[7:0] == 0: return cls(broadcast=True) @@ -1144,8 +1196,9 @@ def from_frame(cls, f, devicetype=0, dev_inst_map=None): return cls(address=None) if f[7] is False and f[0] is True: return cls(address=f[6:1]) + return None - def __str__(self): + def __str__(self) -> str: if self.broadcast: return "Initialise(broadcast=True)" return "Initialise(address={})".format(self.address) diff --git a/dali/gear/incandescent.py b/dali/gear/incandescent.py index e5980b15..5230962c 100644 --- a/dali/gear/incandescent.py +++ b/dali/gear/incandescent.py @@ -1,6 +1,7 @@ """Commands and responses from IEC 62386 part 205.""" -from dali import command +from typing import Optional +from dali import command, frame from dali.gear.general import _StandardCommand, QueryExtendedVersionNumberMixin @@ -104,9 +105,11 @@ class FeaturesByte3Response(command.BitmapResponse): "trailing only", "sine wave"] @property - def dimming_method(self): - if self._value: - return self._dimming_methods[self._value[1:0]] + def dimming_method(self) -> Optional[str]: + if not self.value: + return None + assert isinstance(self.value, frame.BackwardFrame) + return self._dimming_methods[self.value[1:0]] class QueryFeatures(_IncandescentCommand): diff --git a/dali/gear/led.py b/dali/gear/led.py index f75982c4..fc842ce5 100644 --- a/dali/gear/led.py +++ b/dali/gear/led.py @@ -214,14 +214,14 @@ class FastFadeTimeResponse(command.NumericResponse): Refer to Part 207 section 9.13 and Table 1 "Fast fade time". """ - def __str__(self): + def __str__(self) -> str: if isinstance(self.value, int): if self.value == 0: return "shortest" if self.value > 27: return f"out of range ({self.value})" return f"{self.value * 25} ms" - return self.value + return str(self.value) class QueryFastFadeTime(_LEDCommand): diff --git a/dali/gear/sequences.py b/dali/gear/sequences.py index 4baf8a6a..8dfb1735 100644 --- a/dali/gear/sequences.py +++ b/dali/gear/sequences.py @@ -4,9 +4,9 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Generator, Optional +from typing import Generator, Optional, TypeVar -from dali import command +from dali import command, sequences from dali.address import GearAddress, GearShort from dali.exceptions import DALISequenceError from dali.gear.colour import ( @@ -35,6 +35,14 @@ from dali.gear.general import DTR0, DTR1, DTR2, QueryActualLevel, QueryContentDTR0 +# Gear-related sequences will be moved here from dali.sequences at +# some point. Make the new locations work now. +QueryDeviceTypes = sequences.QueryDeviceTypes +QueryGroups = sequences.QueryGroups +SetGroups = sequences.SetGroups +Commissioning = sequences.Commissioning + + def SetDT8ColourValueTc( address: GearAddress, tc_mired: int, @@ -97,6 +105,7 @@ def QueryDT8ColourValue( isinstance(msb, command.NumericResponseMask) and isinstance(lsb, command.NumericResponse) and isinstance(msb.value, int) + and isinstance(lsb.value, int) ): try: col_val = int.from_bytes((lsb.value, msb.value), "little") @@ -155,9 +164,17 @@ class EmergencyInformation: rated_duration: command.NumericResponse -def _require(response: command.Response, what: str) -> command.Response: +ResponseType = TypeVar("ResponseType", bound=command.Response) + + +def _require(response: Optional[command.Response], + responsetype: type[ResponseType], + what: str) -> ResponseType: """Return the response, or abort the sequence if the gear did not answer or replied with a framing error.""" + if response is None: + raise DALISequenceError(f"Missing response to {what}") + assert isinstance(response, responsetype) raw = response.raw_value if raw is None: raise DALISequenceError(f"No response to {what}") @@ -192,35 +209,43 @@ def QueryEmergencyInformation( return EmergencyInformation( emergency_mode=_require( - (yield QueryEmergencyMode(address)), "QueryEmergencyMode" + (yield QueryEmergencyMode(address)), QueryEmergencyModeResponse, + "QueryEmergencyMode" ), emergency_features=_require( - (yield QueryEmergencyFeatures(address)), "QueryEmergencyFeatures" + (yield QueryEmergencyFeatures(address)), + QueryEmergencyFeaturesResponse, "QueryEmergencyFeatures" ), emergency_failure_status=_require( (yield QueryEmergencyFailureStatus(address)), - "QueryEmergencyFailureStatus", + QueryEmergencyFailureStatusResponse, "QueryEmergencyFailureStatus" ), emergency_status=_require( - (yield QueryEmergencyStatus(address)), "QueryEmergencyStatus" + (yield QueryEmergencyStatus(address)), + QueryEmergencyStatusResponse, "QueryEmergencyStatus" ), battery_charge=_require( - (yield QueryBatteryCharge(address)), "QueryBatteryCharge" + (yield QueryBatteryCharge(address)), + command.NumericResponseMask, "QueryBatteryCharge" ), emergency_level=_require( - (yield QueryEmergencyLevel(address)), "QueryEmergencyLevel" + (yield QueryEmergencyLevel(address)), + command.NumericResponseMask, "QueryEmergencyLevel" ), duration_test_result=_require( - (yield QueryDurationTestResult(address)), "QueryDurationTestResult" + (yield QueryDurationTestResult(address)), + command.NumericResponse, "QueryDurationTestResult" ), lamp_emergency_time=_require( - (yield QueryLampEmergencyTime(address)), "QueryLampEmergencyTime" + (yield QueryLampEmergencyTime(address)), + command.NumericResponse, "QueryLampEmergencyTime" ), lamp_total_operation_time=_require( (yield QueryLampTotalOperationTime(address)), - "QueryLampTotalOperationTime", + command.NumericResponse, "QueryLampTotalOperationTime" ), rated_duration=_require( - (yield QueryRatedDuration(address)), "QueryRatedDuration" + (yield QueryRatedDuration(address)), + command.NumericResponse, "QueryRatedDuration" ), ) diff --git a/dali/memory/location.py b/dali/memory/location.py index d990009e..a15bbe30 100644 --- a/dali/memory/location.py +++ b/dali/memory/location.py @@ -1,9 +1,10 @@ from __future__ import annotations +from typing import Optional, Iterable, Generator, Any from collections import namedtuple from enum import Enum, auto -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, @@ -15,7 +16,7 @@ ) -def _DTR0(addr: Address, value: int): +def _DTR0(addr: Address, value: int) -> command.Command: """Returns either a Gear or Device version of DTR0""" if isinstance(addr, GearAddress): return gear.general.DTR0(value) @@ -25,7 +26,7 @@ def _DTR0(addr: Address, value: int): raise TypeError(f"Invalid addr: {addr}, expected GearAddress or DeviceAddress") -def _DTR1(addr: Address, value: int): +def _DTR1(addr: Address, value: int) -> command.Command: """Returns either a Gear or Device version of DTR1""" if isinstance(addr, GearAddress): return gear.general.DTR1(value) @@ -35,7 +36,7 @@ def _DTR1(addr: Address, value: int): raise TypeError(f"Invalid addr: {addr}, expected GearAddress or DeviceAddress") -def _EnableWriteMemory(addr: Address): +def _EnableWriteMemory(addr: Address) -> command.Command: """Returns either a Gear or Device version of EnableWriteMemory""" if isinstance(addr, GearAddress): return gear.general.EnableWriteMemory(addr) @@ -45,7 +46,7 @@ def _EnableWriteMemory(addr: Address): raise TypeError(f"Invalid addr: {addr}, expected GearAddress or DeviceAddress") -def _ReadMemoryLocation(addr: GearShort | DeviceShort): +def _ReadMemoryLocation(addr: GearShort | DeviceShort) -> command.Command: """Returns either a Gear or Device version of ReadMemoryLocation""" if isinstance(addr, GearAddress): return gear.general.ReadMemoryLocation(addr) @@ -55,7 +56,7 @@ def _ReadMemoryLocation(addr: GearShort | DeviceShort): raise TypeError(f"Invalid addr: {addr}, expected GearAddress or DeviceAddress") -def _WriteMemoryLocation(addr: Address, value: int): +def _WriteMemoryLocation(addr: Address, value: int) -> command.Command: """Returns either a Gear or Device version of WriteMemoryLocation""" if isinstance(addr, GearAddress): return gear.general.WriteMemoryLocation(value) @@ -65,7 +66,7 @@ def _WriteMemoryLocation(addr: Address, value: int): raise TypeError(f"Invalid addr: {addr}, expected GearAddress or DeviceAddress") -def _WriteMemoryLocationNoReply(addr: Address, value: int): +def _WriteMemoryLocationNoReply(addr: Address, value: int) -> command.Command: """Returns either a Gear or Device version of WriteMemoryLocationNoReply""" if isinstance(addr, GearAddress): return gear.general.WriteMemoryLocationNoReply(value) @@ -75,7 +76,7 @@ def _WriteMemoryLocationNoReply(addr: Address, value: int): raise TypeError(f"Invalid addr: {addr}, expected GearAddress or DeviceAddress") -def _QueryContentDTR0(addr: Address): +def _QueryContentDTR0(addr: Address) -> command.Command: """Returns either a Gear or Device version of QueryContentDTR0""" if isinstance(addr, GearAddress): return gear.general.QueryContentDTR0(addr) @@ -106,16 +107,18 @@ class LockingNotSupported(Exception): pass -class MemoryBank: - MemoryBankEntry = namedtuple( - 'MemoryBankEntry', ['memory_location', 'memory_value']) +MemoryBankEntry = namedtuple( + 'MemoryBankEntry', ['memory_location', 'memory_value']) + - def __init__(self, address, last_address, has_lock=False, has_latch=False): +class MemoryBank: + def __init__(self, address: int, last_address: int, + has_lock: bool = False, has_latch: bool = False) -> None: """Declares a memory bank at a given address """ self.__address = address - self.locations = {x: None for x in range(0xff)} - self.values = [] + self.locations: dict[int, Optional[MemoryBankEntry]] = {x: None for x in range(0xff)} + self.values: list[MemoryValue] = [] # add value for last addressable location class LastAddress(NumericValue): @@ -132,40 +135,45 @@ class LockByte(NumericValue): latch = has_latch locations = (MemoryLocation( 0x02, reset=0xff, default=0xff, type_=MemoryType.RAM_RW),) - self.LockByte = LockByte + self.LockByte: Optional[type[LockByte]] = LockByte else: self.LockByte = None @property - def address(self): + def address(self) -> int: return self.__address @property - def has_lock(self): + def has_lock(self) -> bool: return bool(self.LockByte and self.LockByte.lock) @property - def has_latch(self): + def has_latch(self) -> bool: return bool(self.LockByte and self.LockByte.latch) - def _add_memory_value(self, memory_value): + def _add_memory_value(self, memory_value: MemoryValue) -> None: self.values.append(memory_value) + # The _RegisterMemoryValue metaclass should already have + # converted this to an iterable + assert not isinstance(memory_value.locations, MemoryLocation) for location in memory_value.locations: if self.locations[location.address]: raise MemoryLocationOverlap( f'Overlapping MemoryLocation at address {location.address}') if location.type_ == MemoryType.NVM_RW_L and not self.has_lock: raise LockingNotSupported() - self.locations[location.address] = self.MemoryBankEntry( + self.locations[location.address] = MemoryBankEntry( location, memory_value) - def last_address(self, addr): + def last_address( + self, addr: GearShort | DeviceShort + ) -> Generator[command.Command, Optional[command.Response], int]: """Sequence that returns the last available address in this bank """ la = yield from self.LastAddress.read(addr) return la - def read_all(self, addr: Address, use_latch: bool = True): + def read_all(self, addr: GearShort | DeviceShort, use_latch: bool = True): """Read all available memory values from this memory bank. If the memory bank has a latch, the latch is set during the @@ -271,26 +279,28 @@ def __repr__(self): # Python 3.7 class MemoryLocation: - def __init__(self, address, default=None, reset=None, type_=None): + def __init__(self, address: int, default: Optional[int] = None, + reset: Optional[int] = None, + type_: Optional[MemoryType] = None) -> None: self.__address = address self.__type_ = type_ self.__default = default self.__reset = reset @property - def address(self): + def address(self) -> int: return self.__address @property - def type_(self): + def type_(self) -> Optional[MemoryType]: return self.__type_ @property - def default(self): + def default(self) -> Optional[int]: return self.__default @property - def reset(self): + def reset(self) -> Optional[int]: return self.__reset def __repr__(self): @@ -378,8 +388,11 @@ class MemoryValue(metaclass=_RegisterMemoryValue): # and/or TMASK? signed = False + bank: MemoryBank + locations: MemoryLocation | Iterable[MemoryLocation] + @classmethod - def raw_to_value(cls, raw): + def raw_to_value(cls, raw: bytes) -> Any: """Converts raw bytes to the wanted result This method should only be called with valid values for 'raw'. @@ -388,7 +401,7 @@ def raw_to_value(cls, raw): return raw @classmethod - def value_to_raw(cls, value): + def value_to_raw(cls, value: Any) -> bytes: """Converts a value to raw bytes to write to the memory bank If the conversion cannot be performed, raises ValueError @@ -396,12 +409,12 @@ def value_to_raw(cls, value): raise ValueError @classmethod - def is_valid(cls, raw): + def is_valid(cls, raw: bytes) -> bool: """Check whether raw bytes are valid for this memory value""" return True @classmethod - def check_raw(cls, raw): + def check_raw(cls, raw: bytes) -> Optional[FlagValue]: """Check for invalid or special patterns in raw bytes Returns None if no invalid or special patterns were found, otherwise @@ -417,7 +430,7 @@ def check_raw(cls, raw): return FlagValue.Invalid @classmethod - def read_raw(cls, addr: Address): + def read_raw(cls, addr: Address) -> bytes: """Read the value from the bus unit without interpretation Raises MemoryLocationNotImplemented if the device does @@ -430,7 +443,7 @@ def read_raw(cls, addr: Address): addr = GearShort(addr) elif not isinstance(addr, (GearShort, DeviceShort)): raise TypeError(f"Invalid addr: {addr}, expected GearShort or DeviceShort") - result = [] + result: list[int] = [] dtr0 = None yield _DTR1(addr, cls.bank.address) for location in cls.locations: @@ -632,11 +645,11 @@ class NumericValue(MemoryValue): max_value = None @classmethod - def raw_to_value(cls, raw): + def raw_to_value(cls, raw: bytes) -> int: return int.from_bytes(raw, 'big', signed=cls.signed) @classmethod - def value_to_raw(cls, value): + def value_to_raw(cls, value: int) -> bytes | FlagValue: if cls.mask_supported and value == 'MASK': return cls.mask if cls.tmask_supported and value == 'TMASK': @@ -646,7 +659,7 @@ def value_to_raw(cls, value): return value.to_bytes(len(cls.locations), 'big', signed=cls.signed) @classmethod - def is_valid(cls, raw): + def is_valid(cls, raw: bytes) -> bool: trial = int.from_bytes(raw, 'big', signed=cls.signed) if cls.min_value is not None: if trial < cls.min_value: diff --git a/dali/sequences.py b/dali/sequences.py index ff444bb0..0c5fabef 100644 --- a/dali/sequences.py +++ b/dali/sequences.py @@ -18,10 +18,31 @@ # Sequences may raise exceptions, which the driver should pass to the # caller. +from typing import Optional, Generator, Literal, Iterable from dali.exceptions import DALISequenceError, ProgramShortAddressFailure - -from dali.gear.general import * -from dali.address import Broadcast, Short +from dali import command +from dali.gear.general import ( + QueryDeviceType, + QueryNextDeviceType, + QueryGroupsZeroToSeven, + QueryGroupsEightToFifteen, + AddToGroup, + RemoveFromGroup, + SetSearchAddrH, + SetSearchAddrM, + SetSearchAddrL, + Compare, + DTR0, + QueryControlGearPresent, + Initialise, + Randomise, + Terminate, + Withdraw, + SetShortAddress, + ProgramShortAddress, + VerifyShortAddress, +) +from dali.address import GearAddress, GearBroadcast, GearShort class sleep: @@ -30,7 +51,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,22 +63,28 @@ 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: Optional[str] = None, + completed: Optional[int] = None, + size: Optional[int] = None): self.message = message self.completed = completed self.size = size - def __str__(self): + def __str__(self) -> str: if self.message: return self.message if self.completed is not None and self.size is not None: return f"Progress: {self.completed}/{self.size}" + return "No progress" -def QueryDeviceTypes(addr): +def QueryDeviceTypes( + addr: int | 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) + assert isinstance(r, QueryDeviceType.response) if r.raw_value is None: raise DALISequenceError("No response to initial query") if r.raw_value.as_integer < 254: @@ -66,10 +93,11 @@ def QueryDeviceTypes(addr): return [] assert r.raw_value.as_integer == 255 last_seen = 0 - result = [] + result: list[int] = [] while True: r = yield QueryNextDeviceType(addr) - if not r.raw_value: + assert isinstance(r, QueryNextDeviceType.response) + if r.raw_value is None: raise DALISequenceError( "No response to QueryNextDeviceType()") if r.raw_value.as_integer == 254: @@ -84,18 +112,22 @@ def QueryDeviceTypes(addr): result.append(r.raw_value.as_integer) -def QueryGroups(addr): +def QueryGroups( + addr: int | GearAddress +) -> Generator[command.Command, Optional[command.Response], set[int]]: """Obtain the group membership of control gear. Returns a set of integers. """ - groups = set() + groups: set[int] = set() g0 = yield QueryGroupsZeroToSeven(addr) + assert isinstance(g0, QueryGroupsZeroToSeven.response) if g0.raw_value is None: raise DALISequenceError("No response reading groups zero to seven") if g0.raw_value.error: raise DALISequenceError("Framing error reading groups zero to seven") g1 = yield QueryGroupsEightToFifteen(addr) + assert isinstance(g1, QueryGroupsEightToFifteen.response) if g1.raw_value is None: raise DALISequenceError("No response reading groups eight to fifteen") if g1.raw_value.error: @@ -107,12 +139,14 @@ def QueryGroups(addr): return groups -def SetGroups(addr, groups): +def SetGroups( + addr: int | GearAddress, groups: set[int] +) -> Generator[command.Command, Optional[command.Response], None]: """Set the group membership of control gear. groups is a set of integers in the range 0..15 """ - if isinstance(addr, Short) or isinstance(addr, int): + if isinstance(addr, GearShort) or isinstance(addr, int): existing = yield from QueryGroups(addr) for i in groups - existing: yield AddToGroup(addr, i) @@ -127,17 +161,20 @@ def SetGroups(addr, groups): yield RemoveFromGroup(addr, i) -def _find_next(low, high): +def _find_next( + low: int, high: int +) -> Generator[command.Command, Optional[command.Response], + Optional[Literal["clash"] | int]]: yield SetSearchAddrH((high >> 16) & 0xff) yield SetSearchAddrM((high >> 8) & 0xff) yield SetSearchAddrL(high & 0xff) r = yield Compare() - + assert isinstance(r, Compare.response) if low == high: - if r.value is True: - return "clash" if r.raw_value.error else low - return + if r.as_boolean is True: + return "clash" if r.raw_value and r.raw_value.error else low + return None if r.value is True: midpoint = (low + high) // 2 @@ -145,10 +182,16 @@ def _find_next(low, high): if res is not None: return res return (yield from _find_next(midpoint + 1, high)) + return None -def Commissioning(available_addresses=None, readdress=False, - dry_run=False): +def Commissioning( + available_addresses: Optional[Iterable[int]] = None, + readdress: bool = False, + dry_run: bool = False +) -> Generator[command.Command | sleep | progress, + Optional[command.Response], + None]: """Assign short addresses to control gear If available_addresses is passed, only the specified addresses @@ -173,12 +216,13 @@ def Commissioning(available_addresses=None, readdress=False, "short addresses") else: yield DTR0(255) - yield SetShortAddress(Broadcast()) + yield SetShortAddress(GearBroadcast()) else: # We need to know which short addresses are already in use for a in range(0, 64): if a in available_addresses: - in_use = yield QueryControlGearPresent(Short(a)) + in_use = yield QueryControlGearPresent(GearShort(a)) + assert isinstance(in_use, QueryControlGearPresent.response) if in_use.value: available_addresses.remove(a) yield progress( @@ -197,19 +241,22 @@ def Commissioning(available_addresses=None, readdress=False, # Randomise can take up to 100ms yield sleep(0.1) - low = 0 + low: Optional[int] = 0 high = 0xffffff while low is not None: yield progress(completed=low, size=high) - low = yield from _find_next(low, high) - if low == "clash": + new_low = yield from _find_next(low, high) + if new_low == "clash": yield progress(message="Multiple ballasts picked the same " "random address; restarting") break - if low is None: + elif new_low is None: + low = None finished = True break + else: + low = new_low yield progress( message=f"Ballast found at address {low:#x}") if available_addresses: @@ -223,7 +270,8 @@ def Commissioning(available_addresses=None, readdress=False, message=f"Programming short address {new_addr}") yield ProgramShortAddress(new_addr) r = yield VerifyShortAddress(new_addr) - if r.value is not True: + assert isinstance(r, VerifyShortAddress.response) + if r.as_boolean is not True: raise ProgramShortAddressFailure(new_addr) else: yield progress( diff --git a/dali/tests/test_device_sequences.py b/dali/tests/test_device_sequences.py index 3f25f508..0d93c9be 100644 --- a/dali/tests/test_device_sequences.py +++ b/dali/tests/test_device_sequences.py @@ -1,7 +1,7 @@ import pytest from dali.address import DeviceShort, InstanceNumber -from dali.command import NumericResponse, YesNoResponse +from dali.command import NumericResponse from dali.device import pushbutton from dali.device.general import ( DTR0, @@ -17,7 +17,7 @@ QueryResolution, SetEventFilter, ) -from dali.device.helpers import DeviceInstanceTypeMapper, check_bad_rsp +from dali.device.helpers import DeviceInstanceTypeMapper from dali.device.pushbutton import InstanceEventFilter as EventFilter_pb from dali.device.sequences import ( Commissioning, @@ -30,32 +30,6 @@ from dali.tests import fakes -def test_check_bad_rsp_none(): - # A 'None' is always a bad response - assert check_bad_rsp(None) - - -def test_check_bad_rsp_yes(): - # A 'Yes' is always a good response - assert not check_bad_rsp(YesNoResponse(BackwardFrame(0xFF))) - - -def test_check_bad_rsp_numeric(): - # A numeric is always a good response, even if 0 - assert not check_bad_rsp(NumericResponse(BackwardFrame(0xFF))) - assert not check_bad_rsp(NumericResponse(BackwardFrame(0x00))) - - -def test_check_bad_rsp_missing(): - # A numeric response expects a value - assert check_bad_rsp(NumericResponse(None)) - - -def test_check_bad_rsp_framing_error(): - # A numeric response expects a value - assert check_bad_rsp(NumericResponse(BackwardFrameError(0xFF))) - - @pytest.fixture def fakes_bus(): return fakes.Bus( @@ -316,22 +290,13 @@ def test_event_filter_pushbutton_sequence_all(): # The previous test for the sequence adequately checks the remaining logic -def test_event_filter_sequence_int(): - # InstanceEventFilter ultimately inherits from int, so it should be - # possible to use a plain int in the sequence - sequence = SetEventFilters( - device=DeviceShort(0), - instance=InstanceNumber(0), - filter_value=3, - ) - # The first message the sequence should send is DTR0 - try: - cmd = sequence.send(None) - except StopIteration: - raise RuntimeError() - assert isinstance(cmd, DTR0) - assert cmd.frame.as_byte_sequence[2] == 3 - # The previous test for the sequence adequately checks the remaining logic +# There used to be a test_event_filter_sequence_int() test here. Its +# comment said: + +# InstanceEventFilter ultimately inherits from int, so it should be +# possible to use a plain int in the sequence + +# This breaks type checking, so the test has been removed. def test_event_filter_sequence_bad_type(): diff --git a/dali/tests/test_frame.py b/dali/tests/test_frame.py index a9343ebd..8022c219 100644 --- a/dali/tests/test_frame.py +++ b/dali/tests/test_frame.py @@ -3,8 +3,7 @@ class TestFrame(unittest.TestCase): - - def test_frame_bits(self): + def test_frame_bits(self) -> None: """frames can only be initialised with an appropriate number of bits""" self.assertRaises(TypeError, frame.Frame, None) self.assertRaises(TypeError, frame.Frame, "wibble") @@ -15,7 +14,7 @@ def test_frame_bits(self): self.assertEqual(len(frame.Frame(16, 0xffff)), 16) self.assertEqual(len(frame.Frame(256, 1 << 255)), 256) - def test_frame_init_data(self): + def test_frame_init_data(self) -> None: """frames can be initialised with integer or iterable data""" self.assertEqual( frame.Frame(16, 0xffff), @@ -31,7 +30,7 @@ def test_frame_init_data(self): ) self.assertRaises(ValueError, frame.Frame, 24, (0x100, 0xff)) - def test_comparisons(self): + def test_comparisons(self) -> None: """frame comparisons""" self.assertNotEqual(frame.Frame(24, 1), 1) self.assertNotEqual(frame.Frame(24, 1), frame.Frame(16, 1)) @@ -39,7 +38,7 @@ def test_comparisons(self): self.assertEqual(frame.Frame(1) == frame.Frame(2), False) self.assertEqual(frame.Frame(1) != frame.Frame(2), True) - def test_read(self): + def test_read(self) -> None: """frames return correct values when read using index""" # Frame will be 0001 0010 0011 0100 0101 0110 # Index: 3210 9876 5432 1098 7654 3210 @@ -49,7 +48,8 @@ def test_read(self): self.assertEqual(f[20], True) self.assertRaises(IndexError, lambda: f[24]) self.assertRaises(IndexError, lambda: f[-1]) - self.assertRaises(TypeError, lambda: f['wibble']) + self.assertRaises( + TypeError, lambda: f['wibble']) # type: ignore[index] self.assertRaises(TypeError, lambda: f[3:0:2]) self.assertRaises(IndexError, lambda: f[24:20]) self.assertEqual(f[3:0], 6) @@ -62,7 +62,7 @@ def test_read(self): self.assertEqual(f[2:1], 3) self.assertEqual(f[17:10], int("10001101", 2)) - def test_write(self): + def test_write(self) -> None: """writing to frames works correctly using indices and slices""" f = frame.Frame(24) f[1] = True @@ -71,7 +71,8 @@ def test_write(self): self.assertEqual(f[23:0], 6) f[1] = False self.assertEqual(f[23:0], 4) - f[0] = 'yes' + # Assigning a string to a Frame index is not officially supported! + f[0] = 'yes' # type: ignore[assignment] self.assertEqual(f[23:0], 5) f[2] = 0 self.assertEqual(f[23:0], 1) @@ -80,7 +81,7 @@ def test_write(self): with self.assertRaises(IndexError): f[-24] = True with self.assertRaises(TypeError): - f['wobble'] = False + f['wobble'] = False # type: ignore[index] with self.assertRaises(TypeError): f[4:3:2] = 2 # Test large frame that stores data as a long @@ -98,38 +99,42 @@ def test_write(self): with self.assertRaises(ValueError): f[3:0] = 0x10 with self.assertRaises(TypeError): - f[20:20] = 'wibble' + f[20:20] = 'wibble' # type: ignore[assignment] with self.assertRaises(IndexError): f[24:20] = 10 with self.assertRaises(ValueError): f[7:4] = -2 - def test_add(self): + def test_add(self) -> None: """frame concatenation works as expected""" self.assertEqual( frame.Frame(16, 0x1234) + frame.Frame(16, 0x5678), frame.Frame(32, 0x12345678) ) - self.assertRaises(TypeError, lambda: frame.Frame(8, 0xff) + 1) - self.assertRaises(TypeError, lambda: 1 + frame.Frame(8, 0xff)) - - def test_as_integer(self): + self.assertRaises( + TypeError, + lambda: frame.Frame(8, 0xff) + 1) + self.assertRaises( + TypeError, + lambda: 1 + frame.Frame(8, 0xff)) # type: ignore[operator] + + def test_as_integer(self) -> None: """returning frame as integer works as expected""" self.assertEqual(frame.Frame(32, 0x12345678).as_integer, 0x12345678) - def test_as_byte_sequence(self): + def test_as_byte_sequence(self) -> None: """constructing frames from byte sequence works as expected""" f = frame.Frame(29, 0x12345678) self.assertEqual(frame.Frame(29, f.as_byte_sequence), f) - def test_pack(self): + def test_pack(self) -> None: """frame packing return expected byte strings""" f = frame.Frame(28, 0x2345678) self.assertEqual(f.pack, b'\x02\x34\x56\x78') f = frame.Frame(16, 0xaa55) self.assertEqual(f.pack, b'\xaa\x55') - def test_pack_len(self): + def test_pack_len(self) -> None: """frame packing with length returns expected byte strings""" f = frame.Frame(28, 0x2345678) self.assertRaises(OverflowError, lambda: f.pack_len(3)) @@ -140,7 +145,7 @@ def test_pack_len(self): self.assertEqual(f.pack_len(2), b'\xaa\x55') self.assertEqual(f.pack_len(4), b'\x00\x00\xaa\x55') - def test_contains(self): + def test_contains(self) -> None: """frame __contains__ method works as expected""" self.assertTrue(True in frame.Frame(16, 0xaa55)) self.assertTrue(False in frame.Frame(16, 0xaa55)) @@ -148,7 +153,7 @@ def test_contains(self): self.assertFalse(False in frame.Frame(16, 0xffff)) self.assertFalse('wibble' in frame.Frame(16)) - def test_str(self): + def test_str(self) -> None: """frame objects can be converted to strings""" self.assertIsInstance( str(frame.Frame(123, 0x12345)), diff --git a/dali/tests/test_serial_driver.py b/dali/tests/test_serial_driver.py index aad20e4a..c8056581 100644 --- a/dali/tests/test_serial_driver.py +++ b/dali/tests/test_serial_driver.py @@ -9,7 +9,6 @@ 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 _DALI_FRAMING_ERROR, DriverLubaRs232 @@ -34,7 +33,6 @@ async def test_timed_out_query_reports_missing_value(): assert isinstance(response, NumericResponse) assert response.value == "(missing)" - assert check_bad_rsp(response) @pytest.mark.asyncio