From 1d6d9136f36b4bd4900240525ed261b03a307cf3 Mon Sep 17 00:00:00 2001 From: Markus Becker Date: Fri, 2 Oct 2026 14:36:46 +0200 Subject: [PATCH 1/4] Add emergency lighting (DT1) memory bank 208 and query helpers Implements the IEC 62386-202 self-contained emergency control gear variable definitions and sequence helpers consumed by application controllers: - dali/memory/emergency.py: memory bank 208 (Table 18) with the control gear temperatures, battery charge/maintenance powers, rated duration, recharge/function-test times, and the full set of failure, mode, test and lamp counters. - dali/gear/sequences.py: QueryEmergencyInformation bundles the DT1 status and measurement queries into a single sequence (so the required ENABLE DEVICE TYPE 1 prefix is injected), and EmergencyCommand issues a single DT1 control command. - Fake gear emergency support plus tests for the bank, the information sequence and the control-command helper. Co-Authored-By: Claude Opus 4.8 --- README.rst | 2 +- dali/gear/sequences.py | 87 +++++++++ dali/memory/__init__.py | 1 + dali/memory/emergency.py | 334 +++++++++++++++++++++++++++++++++++ dali/tests/fakes.py | 50 ++++++ dali/tests/test_emergency.py | 56 ++++++ dali/tests/test_memory.py | 79 ++++++++- 7 files changed, 606 insertions(+), 3 deletions(-) create mode 100644 dali/memory/emergency.py create mode 100644 dali/tests/test_emergency.py diff --git a/README.rst b/README.rst index 476f57f4..d2c9cb92 100644 --- a/README.rst +++ b/README.rst @@ -19,7 +19,7 @@ This library has been written with reference to the following documents: - IEC 62386-102:2014 (general requirements for control gear) - IEC 62386-103:2014 (general requirements for control devices) - IEC 62386-201:2009 (fluorescent lamps) -- IEC 62386-202:2009 (self-contained emergency lighting) +- IEC 62386-202:2022 (self-contained emergency lighting) - IEC 62386-205:2009 (supply voltage controller for incandescent lamps) - IEC 62386-206:2009 (conversion from digital signal into DC voltage) - IEC 62386-207:2009 (LED modules) diff --git a/dali/gear/sequences.py b/dali/gear/sequences.py index e087e7de..9e8e6a26 100644 --- a/dali/gear/sequences.py +++ b/dali/gear/sequences.py @@ -3,6 +3,7 @@ """ from __future__ import annotations +from collections import namedtuple from typing import Generator, Optional from dali import command @@ -14,6 +15,18 @@ SetTemporaryColourTemperature, StoreColourTemperatureTcLimit, ) +from dali.gear.emergency import ( + QueryBatteryCharge, + QueryDurationTestResult, + QueryEmergencyFailureStatus, + QueryEmergencyFeatures, + QueryEmergencyLevel, + QueryEmergencyMode, + QueryEmergencyStatus, + QueryLampEmergencyTime, + QueryLampTotalOperationTime, + QueryRatedDuration, +) from dali.gear.general import DTR0, DTR1, DTR2, QueryActualLevel, QueryContentDTR0 @@ -112,3 +125,77 @@ def SetDT8TcLimit( yield DTR1(tc_bytes[1]) yield DTR2(what_limit) yield StoreColourTemperatureTcLimit(address) + + +EmergencyInformation = namedtuple( + "EmergencyInformation", + [ + "emergency_mode", + "emergency_features", + "emergency_failure_status", + "emergency_status", + "battery_charge", + "emergency_level", + "duration_test_result", + "lamp_emergency_time", + "lamp_total_operation_time", + "rated_duration", + ], +) + + +def _bitmap_byte(response: Optional[command.Response]) -> Optional[int]: + """Return the raw byte of a bitmap response, or None if unavailable.""" + if response is None: + return None + raw = response.raw_value + if raw is None or raw.error: + return None + return raw.as_integer + + +def _numeric(response: Optional[command.Response]) -> Optional[int]: + """Return the integer value of a numeric response, or None for MASK, + a missing answer or a framing error.""" + if response is None: + return None + value = response.value + return value if isinstance(value, int) else None + + +def QueryEmergencyInformation( + address: GearShort, +) -> Generator[command.Command, Optional[command.Response], EmergencyInformation]: + """Read the DT1 (IEC 62386-202) status and measurement values of an + emergency control gear in a single sequence. + + Running it as a sequence ensures the required "ENABLE DEVICE TYPE 1" + command is sent before each application-extended command, so the gear + answers. The returned values are plain integers (or None where the gear + reports MASK or does not answer); the four status bytes are returned as + raw bytes so the caller can decode individual bits. + + :param address: GearShort address to query + :return: an EmergencyInformation namedtuple + """ + # Although the proper types are expected, ints are common enough for + # addresses and their meaning is unambiguous in this context + if isinstance(address, int): + address = GearShort(address) + + return EmergencyInformation( + emergency_mode=_bitmap_byte((yield QueryEmergencyMode(address))), + emergency_features=_bitmap_byte((yield QueryEmergencyFeatures(address))), + emergency_failure_status=_bitmap_byte( + (yield QueryEmergencyFailureStatus(address)) + ), + emergency_status=_bitmap_byte((yield QueryEmergencyStatus(address))), + battery_charge=_numeric((yield QueryBatteryCharge(address))), + emergency_level=_numeric((yield QueryEmergencyLevel(address))), + duration_test_result=_numeric((yield QueryDurationTestResult(address))), + lamp_emergency_time=_numeric((yield QueryLampEmergencyTime(address))), + lamp_total_operation_time=_numeric( + (yield QueryLampTotalOperationTime(address)) + ), + rated_duration=_numeric((yield QueryRatedDuration(address))), + ) diff --git a/dali/memory/__init__.py b/dali/memory/__init__.py index 954283df..c9591db0 100644 --- a/dali/memory/__init__.py +++ b/dali/memory/__init__.py @@ -5,4 +5,5 @@ 'energy', 'diagnostics', 'maintenance', + 'emergency', ] diff --git a/dali/memory/emergency.py b/dali/memory/emergency.py new file mode 100644 index 00000000..3512f740 --- /dev/null +++ b/dali/memory/emergency.py @@ -0,0 +1,334 @@ +"""Memory bank 208 from IEC 62386-202 (self-contained emergency lighting). + +Section 9.15.6, Table 18: information about the control gear, lamp and +battery of a device-type-1 (emergency) control gear. +""" +from .location import ( + FixedScaleNumericValue, + MemoryBank, + MemoryLocation, + MemoryRange, + MemoryType, + NumericValue, + TemperatureValue, +) + +# Table 18 lists 0x32 as the default last addressable location but also +# defines a value at 0x33 ("Performed on this battery"); declare the bank +# up to 0x33 so that value is treated as addressable when a device reports +# it. read_all() always honours the device's own reported last address. +BANK_208 = MemoryBank(208, 0x33, has_lock=True, has_latch=True) + + +class MemoryBankVersion(NumericValue): + """Version of memory bank 208.""" + bank = BANK_208 + locations = MemoryLocation(address=0x03, default=0x01, type_=MemoryType.ROM) + + +class ControlGearMaxReferenceTemperature(TemperatureValue): + """Control gear maximum reference temperature, typically at the Tc point.""" + bank = BANK_208 + locations = MemoryLocation(address=0x04, type_=MemoryType.ROM) + mask_supported = True + min_value = 0 + max_value = 0xFD + + +class ControlGearTemperature(TemperatureValue): + """Internal temperature of the control gear.""" + bank = BANK_208 + locations = MemoryLocation(address=0x05, type_=MemoryType.RAM_RO) + mask_supported = True + tmask_supported = True + min_value = 0 + max_value = 0xFD + + +class MinControlGearTemperatureTotal(TemperatureValue): + """Minimum measured gear temperature (total).""" + bank = BANK_208 + locations = MemoryLocation(address=0x06, type_=MemoryType.NVM_RO) + mask_supported = True + tmask_supported = True + min_value = 0 + max_value = 0xFD + + +class MaxControlGearTemperatureTotal(TemperatureValue): + """Maximum measured gear temperature (total).""" + bank = BANK_208 + locations = MemoryLocation(address=0x07, type_=MemoryType.NVM_RO) + mask_supported = True + tmask_supported = True + min_value = 0 + max_value = 0xFD + + +class MinControlGearTemperatureCurrentBattery(TemperatureValue): + """Minimum measured gear temperature (current battery).""" + bank = BANK_208 + locations = MemoryLocation(address=0x08, type_=MemoryType.NVM_RO) + mask_supported = True + tmask_supported = True + min_value = 0 + max_value = 0xFD + + +class MaxControlGearTemperatureCurrentBattery(TemperatureValue): + """Maximum measured gear temperature (current battery).""" + bank = BANK_208 + locations = MemoryLocation(address=0x09, type_=MemoryType.NVM_RO) + mask_supported = True + tmask_supported = True + min_value = 0 + max_value = 0xFD + + +class AveragePowerDuringCharging(FixedScaleNumericValue): + """Average power during battery charging; 0xFD means 25.3 W or greater.""" + bank = BANK_208 + unit = 'W' + scaling_factor = 0.1 + locations = MemoryLocation(address=0x0A, type_=MemoryType.NVM_RO) + max_value = 0xFD + + +class AveragePowerDuringMaintenance(FixedScaleNumericValue): + """Average power during battery charge maintenance; 0xFD means 25.3 W or + greater.""" + bank = BANK_208 + unit = 'W' + scaling_factor = 0.1 + locations = MemoryLocation(address=0x0B, type_=MemoryType.NVM_RO) + max_value = 0xFD + + +class RatedDuration(FixedScaleNumericValue): + """Rated duration time in minutes (stored in units of 2 min).""" + bank = BANK_208 + unit = 'min' + scaling_factor = 2 + locations = MemoryLocation(address=0x0C, type_=MemoryType.NVM_RO) + + +class FunctionTestTime(NumericValue): + """Nominal function test time in seconds; 0xFD means 253 s or greater.""" + bank = BANK_208 + unit = 's' + locations = MemoryLocation(address=0x0D, type_=MemoryType.NVM_RO) + min_value = 5 + max_value = 0xFD + + +class BatteryRechargeTime(FixedScaleNumericValue): + """Nominal battery recharge time in minutes (stored in units of 10 min); + 0xFD means 2530 min or greater.""" + bank = BANK_208 + unit = 'min' + scaling_factor = 10 + locations = MemoryLocation(address=0x0E, type_=MemoryType.NVM_RO) + min_value = 1 + max_value = 0xFD + + +class BatteryFailureCounter(NumericValue): + """Battery failure counter.""" + bank = BANK_208 + locations = MemoryLocation(address=0x0F, type_=MemoryType.NVM_RO) + mask_supported = True + max_value = 0xFD + + +class BatteryCutOffCounter(NumericValue): + """Count of transitions from below to above battery cut-off voltage; + 0xFD means 253 or greater.""" + bank = BANK_208 + locations = MemoryLocation(address=0x10, type_=MemoryType.NVM_RO) + max_value = 0xFD + + +class LampCutOffCounterTotal(NumericValue): + """Count of transitions from above to below lamp cut-off voltage (total).""" + bank = BANK_208 + locations = MemoryLocation(address=0x11, type_=MemoryType.NVM_RO) + max_value = 0xFD + + +class LampCutOffCounterCurrentBattery(NumericValue): + """Count of transitions from above to below lamp cut-off voltage + (current battery).""" + bank = BANK_208 + locations = MemoryLocation(address=0x12, type_=MemoryType.NVM_RO) + max_value = 0xFD + + +class LampEmergencyTimeTotal(NumericValue): + """Lamp emergency time (total) in minutes; 0xFFFFFD means 16777213 min or + greater.""" + bank = BANK_208 + unit = 'min' + locations = MemoryRange(start=0x13, end=0x15, type_=MemoryType.NVM_RO) + max_value = 0xFFFFFD + + +class LampEmergencyTimeCurrentBattery(NumericValue): + """Lamp emergency time (current battery) in minutes; 0xFFFFFD means + 16777213 min or greater.""" + bank = BANK_208 + unit = 'min' + locations = MemoryRange(start=0x16, end=0x18, type_=MemoryType.NVM_RO) + max_value = 0xFFFFFD + + +class BatteryConnectedTimeTotal(NumericValue): + """Battery connected time (total) in days; 0xFFFD means 65533 days or + greater.""" + bank = BANK_208 + unit = 'd' + locations = MemoryRange(start=0x19, end=0x1A, type_=MemoryType.NVM_RO) + max_value = 0xFFFD + + +class BatteryConnectedTimeCurrentBattery(NumericValue): + """Battery connected time (current battery) in days; 0xFFFD means 65533 + days or greater.""" + bank = BANK_208 + unit = 'd' + locations = MemoryRange(start=0x1B, end=0x1C, type_=MemoryType.NVM_RO) + max_value = 0xFFFD + + +class CircuitFailureCounter(NumericValue): + """Counts transitions of failure status bit 0 (circuit failure).""" + bank = BANK_208 + locations = MemoryLocation(address=0x1D, type_=MemoryType.NVM_RO) + max_value = 0xFD + + +class BatteryDurationFailureCounter(NumericValue): + """Counts transitions of failure status bit 1 (battery duration failure).""" + bank = BANK_208 + locations = MemoryLocation(address=0x1E, type_=MemoryType.NVM_RO) + max_value = 0xFD + + +class BatteryFailureStatusCounter(NumericValue): + """Counts transitions of failure status bit 2 (battery failure).""" + bank = BANK_208 + locations = MemoryLocation(address=0x1F, type_=MemoryType.NVM_RO) + max_value = 0xFD + + +class EmergencyLampFailureCounter(NumericValue): + """Counts transitions of failure status bit 3 (emergency lamp failure).""" + bank = BANK_208 + locations = MemoryLocation(address=0x20, type_=MemoryType.NVM_RO) + max_value = 0xFD + + +class FunctionTestMaxDelayExceededCounter(NumericValue): + """Counts transitions of failure status bit 4.""" + bank = BANK_208 + locations = MemoryLocation(address=0x21, type_=MemoryType.NVM_RO) + max_value = 0xFD + + +class DurationTestMaxDelayExceededCounter(NumericValue): + """Counts transitions of failure status bit 5.""" + bank = BANK_208 + locations = MemoryLocation(address=0x22, type_=MemoryType.NVM_RO) + max_value = 0xFD + + +class FunctionTestFailedCounterTotal(NumericValue): + """Counts transitions of failure status bit 6 (total); 0xFFFD means 65533 + or greater.""" + bank = BANK_208 + locations = MemoryRange(start=0x23, end=0x24, type_=MemoryType.NVM_RO) + max_value = 0xFFFD + + +class DurationTestFailedCounterTotal(NumericValue): + """Counts transitions of failure status bit 7 (total); 0xFD means 253 or + greater.""" + bank = BANK_208 + locations = MemoryLocation(address=0x25, type_=MemoryType.NVM_RO) + max_value = 0xFD + + +class FunctionTestFailedCounterCurrentBattery(NumericValue): + """Counts transitions of failure status bit 6 (current battery); 0xFFFD + means 65533 or greater.""" + bank = BANK_208 + locations = MemoryRange(start=0x26, end=0x27, type_=MemoryType.NVM_RO) + max_value = 0xFFFD + + +class DurationTestFailedCounterCurrentBattery(NumericValue): + """Counts transitions of failure status bit 7 (current battery); 0xFD means + 253 or greater.""" + bank = BANK_208 + locations = MemoryLocation(address=0x28, type_=MemoryType.NVM_RO) + max_value = 0xFD + + +class StartFunctionTestCounter(NumericValue): + """Counts transitions into function test mode; 0xFFFD means 65533 or + greater.""" + bank = BANK_208 + locations = MemoryRange(start=0x29, end=0x2A, type_=MemoryType.NVM_RO) + max_value = 0xFFFD + + +class StartDurationTestCounterTotal(NumericValue): + """Counts transitions into duration test mode (total); 0xFD means 253 or + greater.""" + bank = BANK_208 + locations = MemoryLocation(address=0x2B, type_=MemoryType.NVM_RO) + max_value = 0xFD + + +class StartDurationTestCounterCurrentBattery(NumericValue): + """Counts transitions into duration test mode (current battery); 0xFD means + 253 or greater.""" + bank = BANK_208 + locations = MemoryLocation(address=0x2C, type_=MemoryType.NVM_RO) + max_value = 0xFD + + +class RestModeCounter(NumericValue): + """Counts transitions into rest mode; 0xFFFD means 65533 or greater.""" + bank = BANK_208 + locations = MemoryRange(start=0x2D, end=0x2E, type_=MemoryType.NVM_RO) + max_value = 0xFFFD + + +class EmergencyModeCounterTotal(NumericValue): + """Counts transitions into emergency mode (total); 0xFFFD means 65533 or + greater.""" + bank = BANK_208 + locations = MemoryRange(start=0x2F, end=0x30, type_=MemoryType.NVM_RO) + max_value = 0xFFFD + + +class EmergencyModeCounterCurrentBattery(NumericValue): + """Counts transitions into emergency mode (current battery); 0xFFFD means + 65533 or greater.""" + bank = BANK_208 + locations = MemoryRange(start=0x31, end=0x32, type_=MemoryType.NVM_RO) + max_value = 0xFFFD + + +class PerformedOnThisBattery(NumericValue): + """Bitmap of modes/tests that occurred on the current battery. + + Bit 0: rest mode was active at least once + Bit 1: battery is successfully charging or maintained at full charge + Bit 2: emergency mode was active at least once + Bit 3: extended emergency mode was active at least once + Bit 4: function test was active at least once + Bit 5: duration test was active at least once + """ + bank = BANK_208 + locations = MemoryLocation(address=0x33, type_=MemoryType.RAM_RO) diff --git a/dali/tests/fakes.py b/dali/tests/fakes.py index 3885cccf..a410501c 100644 --- a/dali/tests/fakes.py +++ b/dali/tests/fakes.py @@ -130,6 +130,19 @@ def __init__(self, shortaddr=None, groups=set(), self.ct_mired_min = 153 self.ct_mired_max = 370 self.physical_minimum = 1 + # DT1 (IEC 62386-202) emergency lighting state; only used when 1 is + # in devicetypes. Defaults describe a healthy type-A gear in normal + # mode with a fully charged battery. + self.emergency_mode = 0b00000010 # normal mode + self.emergency_features = 0b00000011 # integral + maintained (type A) + self.emergency_failure_status = 0x00 + self.emergency_status = 0b00001000 # battery fully charged + self.battery_charge = 254 + self.emergency_level = 254 + self.duration_test_result = 60 + self.lamp_emergency_time = 5 + self.lamp_total_operation_time = 100 + self.rated_duration = 90 self.memory_banks = {} for fake_bank in memory_banks: bank_number = fake_bank.bank.address @@ -438,6 +451,43 @@ def send(self, cmd): elif isinstance(cmd, gear.colour.QueryExtendedVersionNumber): return 2 + # Handle DT1 emergency lighting commands; device type decoding has + # already been handled + if isinstance(cmd, gear.emergency._EmergencyLightingCommand): + # Only handle if this Gear instance has been declared as a DT1 type + if 1 not in self.devicetypes: + return + if isinstance(cmd, gear.emergency.QueryEmergencyMode): + return self.emergency_mode + elif isinstance(cmd, gear.emergency.QueryEmergencyFeatures): + return self.emergency_features + elif isinstance(cmd, gear.emergency.QueryEmergencyFailureStatus): + return self.emergency_failure_status + elif isinstance(cmd, gear.emergency.QueryEmergencyStatus): + return self.emergency_status + elif isinstance(cmd, gear.emergency.QueryBatteryCharge): + return self.battery_charge + elif isinstance(cmd, gear.emergency.QueryEmergencyLevel): + return self.emergency_level + elif isinstance(cmd, gear.emergency.QueryDurationTestResult): + return self.duration_test_result + elif isinstance(cmd, gear.emergency.QueryLampEmergencyTime): + return self.lamp_emergency_time + elif isinstance(cmd, gear.emergency.QueryLampTotalOperationTime): + return self.lamp_total_operation_time + elif isinstance(cmd, gear.emergency.QueryRatedDuration): + return self.rated_duration + elif isinstance(cmd, gear.emergency.StartFunctionTest): + self.emergency_status |= 0b00010000 # function test pending + elif isinstance(cmd, gear.emergency.StartDurationTest): + self.emergency_status |= 0b00100000 # duration test pending + elif isinstance(cmd, gear.emergency.StopTest): + self.emergency_status &= ~0b00110000 # clear both pending bits + elif isinstance(cmd, gear.emergency.Inhibit): + self.emergency_status |= 0b00000001 # inhibit mode + elif isinstance(cmd, gear.emergency.ReLightResetInhibit): + self.emergency_status &= ~0b00000001 + # Valid bank 0 ROM contents compatible with IEC 62386-103 class FakeDeviceBank0(FakeMemoryBank): diff --git a/dali/tests/test_emergency.py b/dali/tests/test_emergency.py new file mode 100644 index 00000000..cb39124c --- /dev/null +++ b/dali/tests/test_emergency.py @@ -0,0 +1,56 @@ +from dali.frame import BackwardFrame +from dali.gear.emergency import ( + QueryEmergencyFailureStatusResponse, + QueryEmergencyModeResponse, + QueryEmergencyStatusResponse, +) +from dali.gear.sequences import QueryEmergencyInformation +from dali.tests import fakes + + +def test_emergency_status_decoding(): + rsp = QueryEmergencyStatusResponse(BackwardFrame(0b00001010)) + assert rsp.inhibit_mode == 0 + assert rsp.function_test_done_and_result_valid == 1 + assert rsp.duration_test_done_and_result_valid == 0 + assert rsp.battery_fully_charged == 1 + assert set(rsp.status) == { + "function test done and result valid", + "battery fully charged", + } + + +def test_emergency_failure_decoding(): + rsp = QueryEmergencyFailureStatusResponse(BackwardFrame(0b00000101)) + assert rsp.circuit_failure == 1 + assert rsp.battery_duration_failure == 0 + assert rsp.battery_failure == 1 + + +def test_emergency_mode_property(): + # Only the "emergency mode" bit set + rsp = QueryEmergencyModeResponse(BackwardFrame(0b00000100)) + assert rsp.mode == "emergency mode" + + +def test_query_emergency_information(): + bus = fakes.Bus([fakes.Gear(shortaddr=0, devicetypes=[1])]) + info = bus.run_sequence(QueryEmergencyInformation(0)) + assert info.emergency_mode == 0b00000010 + assert info.emergency_features == 0b00000011 + assert info.emergency_failure_status == 0 + assert info.emergency_status == 0b00001000 + assert info.battery_charge == 254 + assert info.emergency_level == 254 + assert info.duration_test_result == 60 + assert info.lamp_emergency_time == 5 + assert info.lamp_total_operation_time == 100 + assert info.rated_duration == 90 + + +def test_query_emergency_information_mask(): + gear = fakes.Gear(shortaddr=0, devicetypes=[1]) + gear.battery_charge = 255 # MASK + bus = fakes.Bus([gear]) + info = bus.run_sequence(QueryEmergencyInformation(0)) + assert info.battery_charge is None diff --git a/dali/tests/test_memory.py b/dali/tests/test_memory.py index 4cc0dc31..47dae877 100644 --- a/dali/tests/test_memory.py +++ b/dali/tests/test_memory.py @@ -12,7 +12,7 @@ ) from dali.frame import BackwardFrame from dali.gear.general import DTR0, DTR1, ReadMemoryLocation -from dali.memory import diagnostics, energy, info, maintenance, oem +from dali.memory import diagnostics, emergency, energy, info, maintenance, oem from dali.memory.location import FlagValue, MemoryBank, NumericValue from dali.tests import fakes @@ -157,6 +157,51 @@ class FakeBank207(fakes.FakeMemoryBank): ] +# Self-contained emergency control gear information (IEC 62386-202) +class FakeBank208(fakes.FakeMemoryBank): + bank = emergency.BANK_208 + initial_contents = [ + 0x33, None, 0xff, + 0x01, # Version 1 + 85, # Max reference temperature 25°C + 90, # Control gear temperature 30°C + 50, # Min gear temperature (total) -10°C + 100, # Max gear temperature (total) 40°C + 60, # Min gear temperature (current battery) 0°C + 95, # Max gear temperature (current battery) 35°C + 100, # Average power during charging 10.0W + 50, # Average power during maintenance 5.0W + 90, # Rated duration 180 min + 10, # Function test time 10s + 12, # Battery recharge time 120 min + 0, # Battery failure counter + 3, # Battery cut-off counter + 1, # Lamp cut-off counter (total) + 0, # Lamp cut-off counter (current battery) + 0x00, 0x03, 0xe8, # Lamp emergency time (total) 1000 min + 0x00, 0x01, 0xf4, # Lamp emergency time (current battery) 500 min + 0x01, 0x6d, # Battery connected time (total) 365 days + 0x00, 0x1e, # Battery connected time (current battery) 30 days + 0, # Circuit failure counter + 1, # Battery duration failure counter + 2, # Battery failure status counter + 0, # Emergency lamp failure counter + 0, # Function test max delay exceeded counter + 0, # Duration test max delay exceeded counter + 0x00, 0x05, # Function test failed counter (total) 5 + 1, # Duration test failed counter (total) + 0x00, 0x02, # Function test failed counter (current battery) 2 + 0, # Duration test failed counter (current battery) + 0x00, 0x32, # Start function test counter 50 + 10, # Start duration test counter (total) + 3, # Start duration test counter (current battery) + 0x00, 0x07, # Rest mode counter 7 + 0x00, 0x14, # Emergency mode counter (total) 20 + 0x00, 0x04, # Emergency mode counter (current battery) 4 + 0x0a, # Performed on this battery bitmap + ] + + # Invalid / special memory bank contents class InvalidBank1(fakes.FakeMemoryBank): """Strange version of bank 1 @@ -314,7 +359,8 @@ def setUp(self): self.bus = fakes.Bus([ fakes.Gear(GearShort(0), memory_banks=( fakes.FakeBank0, FakeBank1, FakeBank202, FakeBank203, - FakeBank204, FakeBank205, FakeBank206, FakeBank207)), + FakeBank204, FakeBank205, FakeBank206, FakeBank207, + FakeBank208)), fakes.Gear(GearShort(1), memory_banks=( fakes.FakeBank0, InvalidBank1, InvalidBank202, LatchTestBank203, InvalidBank207)), @@ -542,6 +588,35 @@ def test_invalid_oem_strings(self): self.assertEqual(self.bus.run_sequence( oem.LuminaireIdentification.read(0)), FlagValue.Invalid) + def test_emergency(self): + self._test_value(emergency.MemoryBankVersion, 1) + self._test_value(emergency.ControlGearMaxReferenceTemperature, 25) + self._test_value(emergency.ControlGearTemperature, 30) + self._test_value(emergency.MinControlGearTemperatureTotal, -10) + self._test_value(emergency.MaxControlGearTemperatureTotal, 40) + self._test_value(emergency.AveragePowerDuringCharging, 10.0) + self._test_value(emergency.AveragePowerDuringMaintenance, 5.0) + self._test_value(emergency.RatedDuration, 180) + self._test_value(emergency.FunctionTestTime, 10) + self._test_value(emergency.BatteryRechargeTime, 120) + self._test_value(emergency.BatteryCutOffCounter, 3) + self._test_value(emergency.LampEmergencyTimeTotal, 1000) + self._test_value(emergency.LampEmergencyTimeCurrentBattery, 500) + self._test_value(emergency.BatteryConnectedTimeTotal, 365) + self._test_value(emergency.BatteryDurationFailureCounter, 1) + self._test_value(emergency.BatteryFailureStatusCounter, 2) + self._test_value(emergency.FunctionTestFailedCounterTotal, 5) + self._test_value(emergency.StartFunctionTestCounter, 50) + self._test_value(emergency.EmergencyModeCounterTotal, 20) + self._test_value(emergency.PerformedOnThisBattery, 0x0a) + + def test_emergency_read_all(self): + values = self.bus.run_sequence(emergency.BANK_208.read_all(0)) + self.assertEqual(values[emergency.RatedDuration], 180) + self.assertEqual(values[emergency.ControlGearTemperature], 30) + self.assertEqual(values[emergency.LampEmergencyTimeTotal], 1000) + self.assertEqual(values[emergency.StartFunctionTestCounter], 50) + def test_info(self): # Default bank 0 contents from fakes.py self._test_value(info.GTIN, 1234567654321) From 7c1dba4bd07aea5e91f16b1a45cd37fca95a4125 Mon Sep 17 00:00:00 2001 From: Markus Becker Date: Thu, 8 Oct 2026 08:40:50 +0200 Subject: [PATCH 2/4] Clarify why QueryEmergencyInformation runs as a sequence The driver sends EnableDeviceType before every application-extended command on its own, independent of whether the command is part of a sequence. Reword the docstring to state the actual benefit: bundling the queries into one transaction so nothing interleaves between them. Co-Authored-By: Claude Opus 4.8 --- dali/gear/sequences.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/dali/gear/sequences.py b/dali/gear/sequences.py index 9e8e6a26..655757dc 100644 --- a/dali/gear/sequences.py +++ b/dali/gear/sequences.py @@ -169,11 +169,11 @@ def QueryEmergencyInformation( """Read the DT1 (IEC 62386-202) status and measurement values of an emergency control gear in a single sequence. - Running it as a sequence ensures the required "ENABLE DEVICE TYPE 1" - command is sent before each application-extended command, so the gear - answers. The returned values are plain integers (or None where the gear - reports MASK or does not answer); the four status bytes are returned as - raw bytes so the caller can decode individual bits. + Bundling the queries into one sequence runs them under a single + transaction, so no other command can interleave between them. The + returned values are plain integers (or None where the gear reports MASK + or does not answer); the four status bytes are returned as raw bytes so + the caller can decode individual bits. :param address: GearShort address to query :return: an EmergencyInformation namedtuple From 079b9f6f76dd3d6be361e79137483690709fff9f Mon Sep 17 00:00:00 2001 From: Markus Becker Date: Thu, 8 Oct 2026 08:47:13 +0200 Subject: [PATCH 3/4] Decode the emergency status bytes into typed dataclasses Represent the four DT1 status bytes as EmergencyMode, EmergencyFeatures, EmergencyFailureStatus and EmergencyStatus dataclasses with one bool field per IEC 62386-202 bit, and turn EmergencyInformation itself into a dataclass so every member is typed. Callers no longer have to know the bit layout. Co-Authored-By: Claude Opus 4.8 --- dali/gear/sequences.py | 153 ++++++++++++++++++++++++++++------- dali/tests/test_emergency.py | 44 ++++++++-- 2 files changed, 162 insertions(+), 35 deletions(-) diff --git a/dali/gear/sequences.py b/dali/gear/sequences.py index 655757dc..ca393059 100644 --- a/dali/gear/sequences.py +++ b/dali/gear/sequences.py @@ -3,8 +3,8 @@ """ from __future__ import annotations -from collections import namedtuple -from typing import Generator, Optional +from dataclasses import dataclass, fields +from typing import Generator, Optional, Type, TypeVar from dali import command from dali.address import GearAddress, GearShort @@ -127,31 +127,118 @@ def SetDT8TcLimit( yield StoreColourTemperatureTcLimit(address) -EmergencyInformation = namedtuple( - "EmergencyInformation", - [ - "emergency_mode", - "emergency_features", - "emergency_failure_status", - "emergency_status", - "battery_charge", - "emergency_level", - "duration_test_result", - "lamp_emergency_time", - "lamp_total_operation_time", - "rated_duration", - ], -) +@dataclass(frozen=True) +class EmergencyMode: + """Decoded Emergency Mode Information byte (IEC 62386-202). + + Fields are declared least-significant bit first. + """ + + rest_mode: bool + normal_mode: bool + emergency_mode: bool + extended_emergency_mode: bool + function_test: bool + duration_test: bool + hardwired_inhibit_active: bool + hardwired_switch_on: bool + + +@dataclass(frozen=True) +class EmergencyFeatures: + """Decoded Features information byte (IEC 62386-202). + + Fields are declared least-significant bit first. + """ + + integral_emergency_control_gear: bool + maintained_control_gear: bool + switched_maintained_control_gear: bool + auto_test_capability: bool + adjustable_emergency_level: bool + hardwired_inhibit_supported: bool + physical_selection_supported: bool + relight_in_rest_mode_supported: bool + + +@dataclass(frozen=True) +class EmergencyFailureStatus: + """Decoded Failure Status information byte (IEC 62386-202). + + Fields are declared least-significant bit first. + """ + + circuit_failure: bool + battery_duration_failure: bool + battery_failure: bool + emergency_lamp_failure: bool + function_test_max_delay_exceeded: bool + duration_test_max_delay_exceeded: bool + function_test_failed: bool + duration_test_failed: bool + + +@dataclass(frozen=True) +class EmergencyStatus: + """Decoded Emergency Status information byte (IEC 62386-202). + + Fields are declared least-significant bit first. + """ + + inhibit_mode: bool + function_test_done_and_result_valid: bool + duration_test_done_and_result_valid: bool + battery_fully_charged: bool + function_test_pending: bool + duration_test_pending: bool + identification_active: bool + physically_selected: bool + + +@dataclass(frozen=True) +class EmergencyInformation: + """Decoded DT1 (IEC 62386-202) status and measurement values. + + Each status byte is decoded into its named bits, or None where the gear + did not answer or replied with a framing error. The measurement values + are plain integers, or None where the gear reports MASK or does not + answer. + """ + + emergency_mode: Optional[EmergencyMode] + emergency_features: Optional[EmergencyFeatures] + emergency_failure_status: Optional[EmergencyFailureStatus] + emergency_status: Optional[EmergencyStatus] + battery_charge: Optional[int] + emergency_level: Optional[int] + duration_test_result: Optional[int] + lamp_emergency_time: Optional[int] + lamp_total_operation_time: Optional[int] + rated_duration: Optional[int] -def _bitmap_byte(response: Optional[command.Response]) -> Optional[int]: - """Return the raw byte of a bitmap response, or None if unavailable.""" +_StatusByte = TypeVar("_StatusByte") + + +def _decode_status( + response: Optional[command.Response], + cls: Type[_StatusByte], +) -> Optional[_StatusByte]: + """Decode a bitmap response into one of the status dataclasses, or None if + the gear did not answer or replied with a framing error. + + The dataclass fields are declared least-significant bit first, so each is + assigned the matching bit of the response byte by position. + """ if response is None: return None raw = response.raw_value if raw is None or raw.error: return None - return raw.as_integer + byte = raw.as_integer + return cls( + **{field.name: bool(byte & (1 << pos)) for pos, field in enumerate(fields(cls))} + ) def _numeric(response: Optional[command.Response]) -> Optional[int]: @@ -170,13 +257,13 @@ def QueryEmergencyInformation( emergency control gear in a single sequence. Bundling the queries into one sequence runs them under a single - transaction, so no other command can interleave between them. The - returned values are plain integers (or None where the gear reports MASK - or does not answer); the four status bytes are returned as raw bytes so - the caller can decode individual bits. + transaction, so no other command can interleave between them. The four + status bytes are decoded into their named bits and the measurement values + are returned as plain integers (or None where the gear reports MASK or + does not answer). :param address: GearShort address to query - :return: an EmergencyInformation namedtuple + :return: an EmergencyInformation dataclass """ # Although the proper types are expected, ints are common enough for # addresses and their meaning is unambiguous in this context @@ -184,12 +271,18 @@ def QueryEmergencyInformation( address = GearShort(address) return EmergencyInformation( - emergency_mode=_bitmap_byte((yield QueryEmergencyMode(address))), - emergency_features=_bitmap_byte((yield QueryEmergencyFeatures(address))), - emergency_failure_status=_bitmap_byte( - (yield QueryEmergencyFailureStatus(address)) + emergency_mode=_decode_status( + (yield QueryEmergencyMode(address)), EmergencyMode + ), + emergency_features=_decode_status( + (yield QueryEmergencyFeatures(address)), EmergencyFeatures + ), + emergency_failure_status=_decode_status( + (yield QueryEmergencyFailureStatus(address)), EmergencyFailureStatus + ), + emergency_status=_decode_status( + (yield QueryEmergencyStatus(address)), EmergencyStatus ), - emergency_status=_bitmap_byte((yield QueryEmergencyStatus(address))), battery_charge=_numeric((yield QueryBatteryCharge(address))), emergency_level=_numeric((yield QueryEmergencyLevel(address))), duration_test_result=_numeric((yield QueryDurationTestResult(address))), diff --git a/dali/tests/test_emergency.py b/dali/tests/test_emergency.py index cb39124c..e5e68019 100644 --- a/dali/tests/test_emergency.py +++ b/dali/tests/test_emergency.py @@ -1,10 +1,17 @@ +from dataclasses import astuple + from dali.frame import BackwardFrame from dali.gear.emergency import ( QueryEmergencyFailureStatusResponse, QueryEmergencyModeResponse, QueryEmergencyStatusResponse, ) -from dali.gear.sequences import QueryEmergencyInformation +from dali.gear.sequences import ( + EmergencyFeatures, + EmergencyMode, + EmergencyStatus, + QueryEmergencyInformation, +) from dali.tests import fakes @@ -36,10 +43,37 @@ def test_emergency_mode_property(): def test_query_emergency_information(): bus = fakes.Bus([fakes.Gear(shortaddr=0, devicetypes=[1])]) info = bus.run_sequence(QueryEmergencyInformation(0)) - assert info.emergency_mode == 0b00000010 - assert info.emergency_features == 0b00000011 - assert info.emergency_failure_status == 0 - assert info.emergency_status == 0b00001000 + assert info.emergency_mode == EmergencyMode( + rest_mode=False, + normal_mode=True, + emergency_mode=False, + extended_emergency_mode=False, + function_test=False, + duration_test=False, + hardwired_inhibit_active=False, + hardwired_switch_on=False, + ) + assert info.emergency_features == EmergencyFeatures( + integral_emergency_control_gear=True, + maintained_control_gear=True, + switched_maintained_control_gear=False, + auto_test_capability=False, + adjustable_emergency_level=False, + hardwired_inhibit_supported=False, + physical_selection_supported=False, + relight_in_rest_mode_supported=False, + ) + assert not any(astuple(info.emergency_failure_status)) + assert info.emergency_status == EmergencyStatus( + inhibit_mode=False, + function_test_done_and_result_valid=False, + duration_test_done_and_result_valid=False, + battery_fully_charged=True, + function_test_pending=False, + duration_test_pending=False, + identification_active=False, + physically_selected=False, + ) assert info.battery_charge == 254 assert info.emergency_level == 254 assert info.duration_test_result == 60 From 233603879541f92ac9d2d4f7fd49c3bbcd261004 Mon Sep 17 00:00:00 2001 From: Markus Becker Date: Fri, 16 Oct 2026 14:03:46 +0200 Subject: [PATCH 4/4] Preserve emergency response objects and abort on no answer Review feedback: the QueryEmergencyInformation sequence was throwing away the decoded bit attributes by flattening each response into plain integers and bespoke dataclasses. The dali.gear.emergency responses are command.BitmapResponse subclasses that already expose a named attribute per status bit, and the numeric responses already decode MASK. Keep each query's response object in EmergencyInformation so callers can read the named status bits and measurement values directly, and abort the sequence with a DALISequenceError if any query goes unanswered or returns a framing error instead of returning a dataclass full of error responses. Co-Authored-By: Claude Opus 4.8 --- dali/gear/sequences.py | 204 ++++++++++++----------------------- dali/tests/test_emergency.py | 71 +++++------- 2 files changed, 94 insertions(+), 181 deletions(-) diff --git a/dali/gear/sequences.py b/dali/gear/sequences.py index ca393059..23d42291 100644 --- a/dali/gear/sequences.py +++ b/dali/gear/sequences.py @@ -3,11 +3,12 @@ """ from __future__ import annotations -from dataclasses import dataclass, fields -from typing import Generator, Optional, Type, TypeVar +from dataclasses import dataclass +from typing import Generator, Optional from dali import command from dali.address import GearAddress, GearShort +from dali.exceptions import DALISequenceError from dali.gear.colour import ( Activate, QueryColourValue, @@ -19,10 +20,14 @@ QueryBatteryCharge, QueryDurationTestResult, QueryEmergencyFailureStatus, + QueryEmergencyFailureStatusResponse, QueryEmergencyFeatures, + QueryEmergencyFeaturesResponse, QueryEmergencyLevel, QueryEmergencyMode, + QueryEmergencyModeResponse, QueryEmergencyStatus, + QueryEmergencyStatusResponse, QueryLampEmergencyTime, QueryLampTotalOperationTime, QueryRatedDuration, @@ -127,127 +132,38 @@ def SetDT8TcLimit( yield StoreColourTemperatureTcLimit(address) -@dataclass(frozen=True) -class EmergencyMode: - """Decoded Emergency Mode Information byte (IEC 62386-202). - - Fields are declared least-significant bit first. - """ - - rest_mode: bool - normal_mode: bool - emergency_mode: bool - extended_emergency_mode: bool - function_test: bool - duration_test: bool - hardwired_inhibit_active: bool - hardwired_switch_on: bool - - -@dataclass(frozen=True) -class EmergencyFeatures: - """Decoded Features information byte (IEC 62386-202). - - Fields are declared least-significant bit first. - """ - - integral_emergency_control_gear: bool - maintained_control_gear: bool - switched_maintained_control_gear: bool - auto_test_capability: bool - adjustable_emergency_level: bool - hardwired_inhibit_supported: bool - physical_selection_supported: bool - relight_in_rest_mode_supported: bool - - -@dataclass(frozen=True) -class EmergencyFailureStatus: - """Decoded Failure Status information byte (IEC 62386-202). - - Fields are declared least-significant bit first. - """ - - circuit_failure: bool - battery_duration_failure: bool - battery_failure: bool - emergency_lamp_failure: bool - function_test_max_delay_exceeded: bool - duration_test_max_delay_exceeded: bool - function_test_failed: bool - duration_test_failed: bool - - -@dataclass(frozen=True) -class EmergencyStatus: - """Decoded Emergency Status information byte (IEC 62386-202). - - Fields are declared least-significant bit first. - """ - - inhibit_mode: bool - function_test_done_and_result_valid: bool - duration_test_done_and_result_valid: bool - battery_fully_charged: bool - function_test_pending: bool - duration_test_pending: bool - identification_active: bool - physically_selected: bool - - @dataclass(frozen=True) class EmergencyInformation: - """Decoded DT1 (IEC 62386-202) status and measurement values. + """DT1 (IEC 62386-202) status and measurement values of an emergency + control gear. - Each status byte is decoded into its named bits, or None where the gear - did not answer or replied with a framing error. The measurement values - are plain integers, or None where the gear reports MASK or does not - answer. + Each field holds the query's response object, preserving the attributes + those classes decode: the named status bits (for example + ``emergency_mode.emergency_mode``) and the measurement values + (``battery_charge.value``, which is an integer or ``"MASK"``). """ - emergency_mode: Optional[EmergencyMode] - emergency_features: Optional[EmergencyFeatures] - emergency_failure_status: Optional[EmergencyFailureStatus] - emergency_status: Optional[EmergencyStatus] - battery_charge: Optional[int] - emergency_level: Optional[int] - duration_test_result: Optional[int] - lamp_emergency_time: Optional[int] - lamp_total_operation_time: Optional[int] - rated_duration: Optional[int] - - -_StatusByte = TypeVar("_StatusByte") - - -def _decode_status( - response: Optional[command.Response], - cls: Type[_StatusByte], -) -> Optional[_StatusByte]: - """Decode a bitmap response into one of the status dataclasses, or None if - the gear did not answer or replied with a framing error. - - The dataclass fields are declared least-significant bit first, so each is - assigned the matching bit of the response byte by position. - """ - if response is None: - return None + emergency_mode: QueryEmergencyModeResponse + emergency_features: QueryEmergencyFeaturesResponse + emergency_failure_status: QueryEmergencyFailureStatusResponse + emergency_status: QueryEmergencyStatusResponse + battery_charge: command.NumericResponseMask + emergency_level: command.NumericResponseMask + duration_test_result: command.NumericResponse + lamp_emergency_time: command.NumericResponse + lamp_total_operation_time: command.NumericResponse + rated_duration: command.NumericResponse + + +def _require(response: command.Response, what: str) -> command.Response: + """Return the response, or abort the sequence if the gear did not answer + or replied with a framing error.""" raw = response.raw_value - if raw is None or raw.error: - return None - byte = raw.as_integer - return cls( - **{field.name: bool(byte & (1 << pos)) for pos, field in enumerate(fields(cls))} - ) - - -def _numeric(response: Optional[command.Response]) -> Optional[int]: - """Return the integer value of a numeric response, or None for MASK, - a missing answer or a framing error.""" - if response is None: - return None - value = response.value - return value if isinstance(value, int) else None + if raw is None: + raise DALISequenceError(f"No response to {what}") + if raw.error: + raise DALISequenceError(f"Framing error in response to {what}") + return response def QueryEmergencyInformation( @@ -257,10 +173,14 @@ def QueryEmergencyInformation( emergency control gear in a single sequence. Bundling the queries into one sequence runs them under a single - transaction, so no other command can interleave between them. The four - status bytes are decoded into their named bits and the measurement values - are returned as plain integers (or None where the gear reports MASK or - does not answer). + transaction, so no other command can interleave between them. Each query's + response object is kept in the returned EmergencyInformation, so the caller + can read the named status bits and measurement values the response classes + decode. + + A MASK measurement is a valid answer and is preserved; the sequence aborts + with a DALISequenceError if any query goes unanswered or returns a framing + error. :param address: GearShort address to query :return: an EmergencyInformation dataclass @@ -271,24 +191,36 @@ def QueryEmergencyInformation( address = GearShort(address) return EmergencyInformation( - emergency_mode=_decode_status( - (yield QueryEmergencyMode(address)), EmergencyMode + emergency_mode=_require( + (yield QueryEmergencyMode(address)), "QueryEmergencyMode" + ), + emergency_features=_require( + (yield QueryEmergencyFeatures(address)), "QueryEmergencyFeatures" + ), + emergency_failure_status=_require( + (yield QueryEmergencyFailureStatus(address)), + "QueryEmergencyFailureStatus", + ), + emergency_status=_require( + (yield QueryEmergencyStatus(address)), "QueryEmergencyStatus" + ), + battery_charge=_require( + (yield QueryBatteryCharge(address)), "QueryBatteryCharge" + ), + emergency_level=_require( + (yield QueryEmergencyLevel(address)), "QueryEmergencyLevel" ), - emergency_features=_decode_status( - (yield QueryEmergencyFeatures(address)), EmergencyFeatures + duration_test_result=_require( + (yield QueryDurationTestResult(address)), "QueryDurationTestResult" ), - emergency_failure_status=_decode_status( - (yield QueryEmergencyFailureStatus(address)), EmergencyFailureStatus + lamp_emergency_time=_require( + (yield QueryLampEmergencyTime(address)), "QueryLampEmergencyTime" ), - emergency_status=_decode_status( - (yield QueryEmergencyStatus(address)), EmergencyStatus + lamp_total_operation_time=_require( + (yield QueryLampTotalOperationTime(address)), + "QueryLampTotalOperationTime", ), - battery_charge=_numeric((yield QueryBatteryCharge(address))), - emergency_level=_numeric((yield QueryEmergencyLevel(address))), - duration_test_result=_numeric((yield QueryDurationTestResult(address))), - lamp_emergency_time=_numeric((yield QueryLampEmergencyTime(address))), - lamp_total_operation_time=_numeric( - (yield QueryLampTotalOperationTime(address)) + rated_duration=_require( + (yield QueryRatedDuration(address)), "QueryRatedDuration" ), - rated_duration=_numeric((yield QueryRatedDuration(address))), ) diff --git a/dali/tests/test_emergency.py b/dali/tests/test_emergency.py index e5e68019..80ab2da6 100644 --- a/dali/tests/test_emergency.py +++ b/dali/tests/test_emergency.py @@ -1,17 +1,13 @@ -from dataclasses import astuple +import pytest +from dali.exceptions import DALISequenceError from dali.frame import BackwardFrame from dali.gear.emergency import ( QueryEmergencyFailureStatusResponse, QueryEmergencyModeResponse, QueryEmergencyStatusResponse, ) -from dali.gear.sequences import ( - EmergencyFeatures, - EmergencyMode, - EmergencyStatus, - QueryEmergencyInformation, -) +from dali.gear.sequences import QueryEmergencyInformation from dali.tests import fakes @@ -43,43 +39,20 @@ def test_emergency_mode_property(): def test_query_emergency_information(): bus = fakes.Bus([fakes.Gear(shortaddr=0, devicetypes=[1])]) info = bus.run_sequence(QueryEmergencyInformation(0)) - assert info.emergency_mode == EmergencyMode( - rest_mode=False, - normal_mode=True, - emergency_mode=False, - extended_emergency_mode=False, - function_test=False, - duration_test=False, - hardwired_inhibit_active=False, - hardwired_switch_on=False, - ) - assert info.emergency_features == EmergencyFeatures( - integral_emergency_control_gear=True, - maintained_control_gear=True, - switched_maintained_control_gear=False, - auto_test_capability=False, - adjustable_emergency_level=False, - hardwired_inhibit_supported=False, - physical_selection_supported=False, - relight_in_rest_mode_supported=False, - ) - assert not any(astuple(info.emergency_failure_status)) - assert info.emergency_status == EmergencyStatus( - inhibit_mode=False, - function_test_done_and_result_valid=False, - duration_test_done_and_result_valid=False, - battery_fully_charged=True, - function_test_pending=False, - duration_test_pending=False, - identification_active=False, - physically_selected=False, - ) - assert info.battery_charge == 254 - assert info.emergency_level == 254 - assert info.duration_test_result == 60 - assert info.lamp_emergency_time == 5 - assert info.lamp_total_operation_time == 100 - assert info.rated_duration == 90 + assert info.emergency_mode.normal_mode == 1 + assert info.emergency_mode.emergency_mode == 0 + assert info.emergency_features.integral_emergency_control_gear == 1 + assert info.emergency_features.maintained_control_gear == 1 + assert info.emergency_features.switched_maintained_control_gear == 0 + assert info.emergency_failure_status.status == [] + assert info.emergency_status.battery_fully_charged == 1 + assert info.emergency_status.inhibit_mode == 0 + assert info.battery_charge.value == 254 + assert info.emergency_level.value == 254 + assert info.duration_test_result.value == 60 + assert info.lamp_emergency_time.value == 5 + assert info.lamp_total_operation_time.value == 100 + assert info.rated_duration.value == 90 def test_query_emergency_information_mask(): @@ -87,4 +60,12 @@ def test_query_emergency_information_mask(): gear.battery_charge = 255 # MASK bus = fakes.Bus([gear]) info = bus.run_sequence(QueryEmergencyInformation(0)) - assert info.battery_charge is None + assert info.battery_charge.value == "MASK" + + +def test_query_emergency_information_aborts_on_missing_response(): + gear = fakes.Gear(shortaddr=0, devicetypes=[1]) + gear.emergency_mode = None # gear does not answer the first query + bus = fakes.Bus([gear]) + with pytest.raises(DALISequenceError): + bus.run_sequence(QueryEmergencyInformation(0))