diff --git a/dali/gear/sequences.py b/dali/gear/sequences.py index e087e7de..ca393059 100644 --- a/dali/gear/sequences.py +++ b/dali/gear/sequences.py @@ -3,7 +3,8 @@ """ from __future__ import annotations -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 @@ -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,170 @@ def SetDT8TcLimit( yield DTR1(tc_bytes[1]) yield DTR2(what_limit) 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. + + 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] + + +_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 + 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 + + +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. + + 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). + + :param address: GearShort address to query + :return: an EmergencyInformation dataclass + """ + # 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=_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 + ), + 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..e5e68019 --- /dev/null +++ b/dali/tests/test_emergency.py @@ -0,0 +1,90 @@ +from dataclasses import astuple + +from dali.frame import BackwardFrame +from dali.gear.emergency import ( + QueryEmergencyFailureStatusResponse, + QueryEmergencyModeResponse, + QueryEmergencyStatusResponse, +) +from dali.gear.sequences import ( + EmergencyFeatures, + EmergencyMode, + EmergencyStatus, + 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 == 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 + + +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)