def test_EndTOEnd_group_write_binary_on(self): """Test parsing and streaming CEMIFrame KNX/IP packet, switch on light in my kitchen.""" # Switch on Kitchen-L1 raw = bytes.fromhex("0610053000112900BCD0FFF90149010081") xknx = XKNX() knxipframe = KNXIPFrame(xknx) knxipframe.from_knx(raw) telegram = knxipframe.body.cemi.telegram self.assertEqual( telegram, Telegram( destination_address=GroupAddress("329"), payload=GroupValueWrite(DPTBinary(1)), source_address=IndividualAddress("15.15.249"), ), ) cemi = CEMIFrame(xknx, src_addr=IndividualAddress("15.15.249")) cemi.telegram = telegram cemi.set_hops(5) routing_indication = RoutingIndication(xknx, cemi=cemi) knxipframe2 = KNXIPFrame.init_from_body(routing_indication) self.assertEqual(knxipframe2.header.to_knx(), list(raw[0:6])) self.assertEqual(knxipframe2.body.to_knx(), list(raw[6:])) self.assertEqual(knxipframe2.to_knx(), list(raw))
def test_EndTOEnd_group_write_2bytes(self): """Test parsing and streaming CEMIFrame KNX/IP packet, setting value of thermostat.""" # Incoming Temperature from thermostat raw = bytes.fromhex("0610053000132900BCD01402080103008007C1") xknx = XKNX() knxipframe = KNXIPFrame(xknx) knxipframe.from_knx(raw) telegram = knxipframe.body.cemi.telegram self.assertEqual( telegram, Telegram( destination_address=GroupAddress("2049"), payload=GroupValueWrite( DPTArray(DPTTemperature().to_knx(19.85))), source_address=IndividualAddress("1.4.2"), ), ) cemi = CEMIFrame(xknx, src_addr=IndividualAddress("1.4.2")) cemi.telegram = telegram cemi.set_hops(5) routing_indication = RoutingIndication(xknx, cemi=cemi) knxipframe2 = KNXIPFrame.init_from_body(routing_indication) self.assertEqual(knxipframe2.header.to_knx(), list(raw[0:6])) self.assertEqual(knxipframe2.body.to_knx(), list(raw[6:])) self.assertEqual(knxipframe2.to_knx(), list(raw))
def test_EndTOEnd_group_response(self): """Test parsing and streaming CEMIFrame KNX/IP packet, group response.""" # Incoming state raw = bytes.fromhex("0610053000112900BCD013010188010041") xknx = XKNX() knxipframe = KNXIPFrame(xknx) knxipframe.from_knx(raw) telegram = knxipframe.body.cemi.telegram self.assertEqual( telegram, Telegram( destination_address=GroupAddress("392"), payload=GroupValueResponse(DPTBinary(1)), source_address=IndividualAddress("1.3.1"), ), ) cemi = CEMIFrame(xknx, src_addr=IndividualAddress("1.3.1")) cemi.telegram = telegram cemi.set_hops(5) routing_indication = RoutingIndication(xknx, cemi=cemi) knxipframe2 = KNXIPFrame.init_from_body(routing_indication) self.assertEqual(knxipframe2.header.to_knx(), list(raw[0:6])) self.assertEqual(knxipframe2.body.to_knx(), list(raw[6:])) self.assertEqual(knxipframe2.to_knx(), list(raw))
async def test_nm_individual_address_check_refused(_if_mock): """Test nm_individual_address_check.""" xknx = XKNX() individual_address = IndividualAddress("4.0.10") connect = Telegram(destination_address=individual_address, tpci=tpci.TConnect()) device_desc_read = Telegram( destination_address=individual_address, tpci=tpci.TDataConnected(0), payload=apci.DeviceDescriptorRead(descriptor=0), ) ack = Telegram( source_address=individual_address, destination_address=IndividualAddress(0), direction=TelegramDirection.INCOMING, tpci=tpci.TAck(0), ) disconnect = Telegram( source_address=individual_address, destination_address=IndividualAddress(0), direction=TelegramDirection.INCOMING, tpci=tpci.TDisconnect(), ) task = asyncio.create_task( procedures.nm_individual_address_check(xknx, individual_address)) await asyncio.sleep(0) assert xknx.knxip_interface.send_telegram.call_args_list == [ call(connect), call(device_desc_read), ] xknx.management.process(disconnect) xknx.management.process(ack) assert await task
def from_knx_data_link_layer(self, cemi: bytes) -> int: """Parse L_DATA_IND, CEMIMessageCode.L_DATA_REQ, CEMIMessageCode.L_DATA_CON.""" if len(cemi) < 10: raise UnsupportedCEMIMessage( f"CEMI too small. Length: {len(cemi)}; CEMI: {cemi.hex()}") # AddIL (Additional Info Length), as specified within # KNX Chapter 3.6.3/4.1.4.3 "Additional information." # Additional information is not yet parsed. addil = cemi[1] # Control field 1 and Control field 2 - first 2 octets after Additional information self.flags = cemi[2 + addil] * 256 + cemi[3 + addil] self.src_addr = IndividualAddress((cemi[4 + addil], cemi[5 + addil])) dst_is_group_address = bool(self.flags & CEMIFlags.DESTINATION_GROUP_ADDRESS) dst_raw_address = (cemi[6 + addil], cemi[7 + addil]) self.dst_addr = (GroupAddress(dst_raw_address) if dst_is_group_address else IndividualAddress(dst_raw_address)) npdu_len = cemi[8 + addil] apdu = cemi[9 + addil:] if len(apdu) != (npdu_len + 1): # TCPI octet not included in NPDU length raise CouldNotParseKNXIP( f"APDU LEN should be {npdu_len} but is {len(apdu) - 1} in CEMI: {cemi.hex()}" ) # TPCI (transport layer control information) # - with control bit set -> 8 bit; no APDU # - no control bit set (data) -> First 6 bit # APCI (application layer control information) -> Last 10 bit of TPCI/APCI try: self.tpci = TPCI.resolve(raw_tpci=cemi[9 + addil], dst_is_group_address=dst_is_group_address) except ConversionError as err: raise UnsupportedCEMIMessage( f"TPCI not supported: {cemi[9 + addil]:#10b}") from err if self.tpci.control: if npdu_len: raise UnsupportedCEMIMessage( f"Invalid length for control TPDU {self.tpci}: {npdu_len}") return 10 + addil _apci = apdu[0] * 256 + apdu[1] try: self.payload = APCI.resolve_apci(_apci) except ConversionError as err: raise UnsupportedCEMIMessage( f"APCI not supported: {_apci:#012b}") from err self.payload.from_knx(apdu) return 10 + addil + npdu_len
def test_valid_command(frame): """Test for valid frame parsing""" packet_len = frame.from_knx(get_data(0x29, 0, 0, 0, 0, 1, 0, [])) assert frame.code == CEMIMessageCode.L_DATA_IND assert frame.flags == 0 assert frame.mpdu_len == 1 assert frame.payload == GroupValueRead() assert frame.src_addr == IndividualAddress(0) assert frame.dst_addr == IndividualAddress(0) assert packet_len == 11
def __init__( self, own_address: str | IndividualAddress = DEFAULT_ADDRESS, address_format: GroupAddressType = GroupAddressType.LONG, telegram_received_cb: Callable[[Telegram], Awaitable[None]] | None = None, device_updated_cb: Callable[[Device], Awaitable[None]] | None = None, connection_state_changed_cb: Callable[[XknxConnectionState], Awaitable[None]] | None = None, rate_limit: int = DEFAULT_RATE_LIMIT, multicast_group: str = DEFAULT_MCAST_GRP, multicast_port: int = DEFAULT_MCAST_PORT, log_directory: str | None = None, state_updater: TrackerOptionType = False, daemon_mode: bool = False, connection_config: ConnectionConfig = ConnectionConfig(), ) -> None: """Initialize XKNX class.""" self.connection_manager = ConnectionManager() self.devices = Devices() self.knxip_interface = knx_interface_factory( self, connection_config=connection_config) self.management = Management(self) self.telegrams: asyncio.Queue[Telegram | None] = asyncio.Queue() self.telegram_queue = TelegramQueue(self) self.state_updater = StateUpdater(self, default_tracker_option=state_updater) self.task_registry = TaskRegistry(self) self.current_address = IndividualAddress(0) self.daemon_mode = daemon_mode self.multicast_group = multicast_group self.multicast_port = multicast_port self.own_address = IndividualAddress(own_address) self.rate_limit = rate_limit self.sigint_received = asyncio.Event() self.started = asyncio.Event() self.version = VERSION GroupAddress.address_format = address_format # for global string representation if log_directory is not None: self.setup_logging(log_directory) if telegram_received_cb is not None: self.telegram_queue.register_telegram_received_cb( telegram_received_cb) if device_updated_cb is not None: self.devices.register_device_updated_cb(device_updated_cb) if connection_state_changed_cb is not None: self.connection_manager.register_connection_state_changed_cb( connection_state_changed_cb)
def test_invalid_payload(): """Test for having wrong payload set.""" frame = CEMIFrame() frame.code = CEMIMessageCode.L_DATA_IND frame.flags = 0 frame.payload = None frame.src_addr = IndividualAddress(0) frame.dst_addr = IndividualAddress(0) with pytest.raises(TypeError): frame.calculated_length() with pytest.raises(ConversionError): frame.to_knx()
def test_valid_tpci_control(): """Test for valid tpci control.""" raw = bytes((0x29, 0, 0, 0, 0, 0, 0, 0, 0, 0x80)) frame = CEMIFrame() packet_len = frame.from_knx(raw) assert frame.code == CEMIMessageCode.L_DATA_IND assert frame.flags == 0 assert frame.payload is None assert frame.src_addr == IndividualAddress(0) assert frame.dst_addr == IndividualAddress(0) assert frame.tpci == TConnect() assert packet_len == 10 assert frame.calculated_length() == 10 assert frame.to_knx() == raw
def test_invalid_payload(frame): """Test for having wrong payload set""" frame.code = CEMIMessageCode.L_DATA_IND frame.flags = 0 frame.mpdu_len = 1 frame.payload = DPTBinary(1) frame.src_addr = IndividualAddress(0) frame.dst_addr = IndividualAddress(0) with raises(TypeError): frame.calculated_length() with raises(TypeError): frame.to_knx()
def from_knx_data_link_layer(self, cemi: bytes) -> int: """Parse L_DATA_IND, CEMIMessageCode.L_DATA_REQ, CEMIMessageCode.L_DATA_CON.""" if len(cemi) < 11: # eg. ETS Line-Scan issues L_DATA_IND with length 10 raise UnsupportedCEMIMessage( "CEMI too small. Length: {}; CEMI: {}".format(len(cemi), cemi.hex()) ) # AddIL (Additional Info Length), as specified within # KNX Chapter 3.6.3/4.1.4.3 "Additional information." # Additional information is not yet parsed. addil = cemi[1] # Control field 1 and Control field 2 - first 2 octets after Additional information self.flags = cemi[2 + addil] * 256 + cemi[3 + addil] self.src_addr = IndividualAddress((cemi[4 + addil], cemi[5 + addil])) if self.flags & CEMIFlags.DESTINATION_GROUP_ADDRESS: self.dst_addr = GroupAddress( (cemi[6 + addil], cemi[7 + addil]), levels=self.xknx.address_format ) else: self.dst_addr = IndividualAddress((cemi[6 + addil], cemi[7 + addil])) self.mpdu_len = cemi[8 + addil] # TPCI (transport layer control information) -> First 14 bit # APCI (application layer control information) -> Last 10 bit apdu = cemi[9 + addil :] if len(apdu) != (self.mpdu_len + 1): raise CouldNotParseKNXIP( "APDU LEN should be {} but is {} in CEMI: {}".format( self.mpdu_len, len(apdu), cemi.hex() ) ) tpci_apci = (apdu[0] << 8) + apdu[1] try: self.payload = APCI.resolve_apci(tpci_apci & 0x03FF) except ConversionError: raise UnsupportedCEMIMessage( "APCI not supported: {:#012b}".format(tpci_apci & 0x03FF) ) self.payload.from_knx(apdu) return 10 + addil + self.mpdu_len
def fake_router_search_response(xknx: XKNX) -> KNXIPFrame: """Return the KNXIPFrame of a KNX/IP Router with a SearchResponse body.""" frame_body = SearchResponse(xknx) frame_body.control_endpoint = HPAI(ip_addr="192.168.42.10", port=3671) device_information = DIBDeviceInformation() device_information.name = "Gira KNX/IP-Router" device_information.serial_number = "11:22:33:44:55:66" device_information.individual_address = IndividualAddress("1.1.0") device_information.mac_address = "01:02:03:04:05:06" svc_families = DIBSuppSVCFamilies() svc_families.families.append( DIBSuppSVCFamilies.Family(name=DIBServiceFamily.CORE, version=1)) svc_families.families.append( DIBSuppSVCFamilies.Family(name=DIBServiceFamily.DEVICE_MANAGEMENT, version=2)) svc_families.families.append( DIBSuppSVCFamilies.Family(name=DIBServiceFamily.TUNNELING, version=1)) svc_families.families.append( DIBSuppSVCFamilies.Family(name=DIBServiceFamily.ROUTING, version=1)) svc_families.families.append( DIBSuppSVCFamilies.Family( name=DIBServiceFamily.REMOTE_CONFIGURATION_DIAGNOSIS, version=1)) frame_body.dibs.append(device_information) frame_body.dibs.append(svc_families) return KNXIPFrame.init_from_body(frame_body)
def test_config_general(self): """Test reading general section from config file.""" self.assertEqual(TestConfig.xknx.own_address, IndividualAddress("15.15.249")) self.assertEqual(TestConfig.xknx.rate_limit, 18) self.assertEqual(TestConfig.xknx.multicast_group, "224.1.2.3") self.assertEqual(TestConfig.xknx.multicast_port, 1337)
def test_dib_device_informatio(self): """Test string representation of DIBDeviceInformation.""" dib = DIBDeviceInformation() dib.knx_medium = KNXMedium.TP1 dib.programming_mode = False dib.individual_address = IndividualAddress("1.1.0") dib.name = "Gira KNX/IP-Router" dib.mac_address = "00:01:02:03:04:05" dib.multicast_address = "224.0.23.12" dib.serial_number = "13:37:13:37:13:37" dib.project_number = 564 dib.installation_number = 2 self.assertEqual( str(dib), "<DIBDeviceInformation \n" '\tknx_medium="KNXMedium.TP1" \n' '\tprogramming_mode="False" \n' '\tindividual_address="1.1.0" \n' '\tinstallation_number="2" \n' '\tproject_number="564" \n' '\tserial_number="13:37:13:37:13:37" \n' '\tmulticast_address="224.0.23.12" \n' '\tmac_address="00:01:02:03:04:05" \n' '\tname="Gira KNX/IP-Router" />', )
async def test_ack_timeout(_if_mock, time_travel): """Test ACK timeout handling.""" xknx = XKNX() _ia = IndividualAddress("4.0.1") conn = await xknx.management.connect(_ia) xknx.knxip_interface.send_telegram.reset_mock() device_desc_read = Telegram( destination_address=_ia, tpci=tpci.TDataConnected(0), payload=apci.DeviceDescriptorRead(descriptor=0), ) task = asyncio.create_task( conn.request( payload=apci.DeviceDescriptorRead(descriptor=0), expected=apci.DeviceDescriptorResponse, )) await asyncio.sleep(0) assert xknx.knxip_interface.send_telegram.call_args_list == [ call(device_desc_read), ] await time_travel(MANAGAMENT_ACK_TIMEOUT) # telegram repeated assert xknx.knxip_interface.send_telegram.call_args_list == [ call(device_desc_read), call(device_desc_read), ] await time_travel(MANAGAMENT_ACK_TIMEOUT) with pytest.raises(ManagementConnectionTimeout): # still no ACK -> timeout await task await conn.disconnect()
def test_telegram_not_equal(self): """Test not equals operator.""" assert Telegram(GroupAddress("1/2/3"), payload=GroupValueRead()) != Telegram( GroupAddress("1/2/4"), payload=GroupValueRead()) assert Telegram( GroupAddress("1/2/3"), payload=GroupValueRead()) != Telegram( GroupAddress("1/2/3"), payload=GroupValueWrite(DPTBinary(1))) assert Telegram(GroupAddress("1/2/3"), payload=GroupValueRead()) != Telegram( GroupAddress("1/2/3"), TelegramDirection.INCOMING, payload=GroupValueRead(), ) assert Telegram(IndividualAddress(1), tpci=TConnect()) != Telegram( IndividualAddress(1), tpci=TDisconnect())
def test_telegram_individual_address(): """Test telegram conversion flags with a individual address.""" frame = CEMIFrame() frame.telegram = Telegram(destination_address=IndividualAddress(0)) assert ( frame.flags & CEMIFlags.DESTINATION_INDIVIDUAL_ADDRESS ) == CEMIFlags.DESTINATION_INDIVIDUAL_ADDRESS
async def test_connect(_if_mock): """Test establishing connections.""" xknx = XKNX() ia_1 = IndividualAddress("4.0.1") ia_2 = IndividualAddress("4.0.2") def tg_connect(ia): return Telegram( source_address=xknx.current_address, destination_address=ia, direction=TelegramDirection.OUTGOING, tpci=tpci.TConnect(), ) def tg_disconnect(ia): return Telegram( source_address=xknx.current_address, destination_address=ia, direction=TelegramDirection.OUTGOING, tpci=tpci.TDisconnect(), ) await xknx.management.connect(ia_1) conn_2 = await xknx.management.connect(ia_2) with pytest.raises(ManagementConnectionError): # no 2 connections to the same IA await xknx.management.connect(ia_1) assert xknx.knxip_interface.send_telegram.call_args_list == [ call(tg_connect(ia_1)), call(tg_connect(ia_2)), ] xknx.knxip_interface.send_telegram.reset_mock() await xknx.management.disconnect(ia_1) await conn_2.disconnect() assert xknx.knxip_interface.send_telegram.call_args_list == [ call(tg_disconnect(ia_1)), call(tg_disconnect(ia_2)), ] # connect again doesn't raise await xknx.management.connect(ia_1)
def init_from_telegram( telegram: Telegram, code: CEMIMessageCode = CEMIMessageCode.L_DATA_IND, src_addr: IndividualAddress = IndividualAddress(None), ) -> CEMIFrame: """Return CEMIFrame from a Telegram.""" cemi = CEMIFrame(code=code, src_addr=src_addr) # dst_addr, payload and cmd are set by telegram.setter - mpdu_len not needed for outgoing telegram cemi.telegram = telegram return cemi
def parse_xml(self, node: Document) -> None: """Parse all needed attributes from the given node map.""" attributes = node.attributes self.type = InterfaceType( self.get_attribute_value(attributes.get("Type"))) self.host = IndividualAddress( self.get_attribute_value(attributes.get("Host"))) self.user_id = int( self.get_attribute_value(attributes.get("UserID")) or 2) self.password = self.get_attribute_value(attributes.get("Password")) self.individual_address = IndividualAddress( self.get_attribute_value(attributes.get("IndividualAddress"))) self.authentication = self.get_attribute_value( attributes.get("Authentication")) for assigned_ga in filter(lambda x: x.nodeType != 3, node.childNodes): group_address: XMLAssignedGroupAddress = XMLAssignedGroupAddress() group_address.parse_xml(assigned_ga) self.group_addresses.append(group_address)
def __init__(self) -> None: """Initialize DIBDeviceInformation class.""" self.knx_medium: KNXMedium = KNXMedium.TP1 self.programming_mode: bool = False self.individual_address: IndividualAddress = IndividualAddress(None) self.installation_number: int = 0 self.project_number: int = 0 self.serial_number: str = "" self.multicast_address: str = "224.0.23.12" self.mac_address: str = "" self.name: str = ""
def test_invalid_dst_addr(frame): """Test for invalid dst addr""" frame.code = CEMIMessageCode.L_DATA_IND frame.flags = 0 frame.mpdu_len = 1 frame.payload = GroupValueRead() frame.src_addr = IndividualAddress(0) frame.dst_addr = None with raises(ConversionError, match=r"dst_addr not set"): frame.to_knx()
def test_invalid_src_addr(): """Test for invalid src addr.""" frame = CEMIFrame() frame.code = CEMIMessageCode.L_DATA_IND frame.flags = 0 frame.payload = GroupValueRead() frame.src_addr = GroupAddress(0) frame.dst_addr = IndividualAddress(0) with pytest.raises(ConversionError, match=r"src_addr invalid"): frame.to_knx()
def test_telegram_individual_address(): """Test telegram conversion flags with a individual address.""" frame = CEMIFrame() _telegram = Telegram(destination_address=IndividualAddress(0), tpci=TConnect()) # test CEMIFrame.telegram setter frame.telegram = _telegram assert frame.flags & 0x0080 == CEMIFlags.DESTINATION_INDIVIDUAL_ADDRESS assert frame.flags & 0x0C00 == CEMIFlags.PRIORITY_SYSTEM assert frame.flags & 0x0200 == CEMIFlags.NO_ACK_REQUESTED # test CEMIFrame.telegram property assert frame.telegram == _telegram
def from_knx(self, raw: bytes) -> int: """Parse/deserialize from KNX/IP raw data.""" if len(raw) < DIBDeviceInformation.LENGTH: raise CouldNotParseKNXIP("wrong connection header length") if raw[0] != DIBDeviceInformation.LENGTH: raise CouldNotParseKNXIP("wrong connection header length") if DIBTypeCode(raw[1]) != DIBTypeCode.DEVICE_INFO: raise CouldNotParseKNXIP("DIB is no device info") self.knx_medium = KNXMedium(raw[2]) # last bit of device_status. All other bits are unused self.programming_mode = bool(raw[3]) self.individual_address = IndividualAddress((raw[4], raw[5])) installation_project_identifier = raw[6] * 256 + raw[7] self.project_number = installation_project_identifier >> 4 self.installation_number = installation_project_identifier & 15 self.serial_number = ":".join("%02x" % i for i in raw[8:14]) self.multicast_address = ".".join("%i" % i for i in raw[14:18]) self.mac_address = ":".join("%02x" % i for i in raw[18:24]) self.name = "".join(map(chr, raw[24:54])).rstrip("\0") return DIBDeviceInformation.LENGTH
def parse_general(self, doc): """Parse the general section of xknx.yaml.""" if "general" in doc: if "own_address" in doc["general"]: self.xknx.own_address = IndividualAddress( doc["general"]["own_address"]) if "rate_limit" in doc["general"]: self.xknx.rate_limit = doc["general"]["rate_limit"] if "multicast_group" in doc["general"]: self.xknx.multicast_group = doc["general"]["multicast_group"] if "multicast_port" in doc["general"]: self.xknx.multicast_port = doc["general"]["multicast_port"]
def parse_xml(self, node: Document) -> None: """Parse all needed attributes from the given node map.""" attributes = node.attributes self.individual_address = IndividualAddress( self.get_attribute_value(attributes.get("IndividualAddress"))) self.tool_key = self.get_attribute_value(attributes.get("ToolKey")) self.management_password = self.get_attribute_value( attributes.get("ManagementPassword")) self.authentication = self.get_attribute_value( attributes.get("Authentication")) self.sequence_number = int( self.get_attribute_value(attributes.get("SequenceNumber", 0)))
def test_with_valid(self): """Test with some valid addresses.""" valid_addresses = ( ("0.0.0", 0), ("123", 123), ("1.0.0", 4096), ("1.1.0", 4352), ("1.1.1", 4353), ("1.1.11", 4363), ("1.1.111", 4463), ("1.11.111", 7023), ("11.11.111", 47983), (IndividualAddress("11.11.111"), 47983), ("15.15.255", 65535), ((0xFF, 0xFF), 65535), (0, 0), (65535, 65535), ) for address in valid_addresses: with self.subTest(address=address): self.assertEqual(IndividualAddress(address[0]).raw, address[1])
def test_gateway_descriptor(self): """Test string representation of GatewayDescriptor.""" gateway_descriptor = GatewayDescriptor( name="KNX-Interface", ip_addr="192.168.2.3", port=1234, local_interface="en1", local_ip="192.168.2.50", supports_tunnelling=True, supports_routing=False, individual_address=IndividualAddress("1.1.1"), ) assert str(gateway_descriptor) == "1.1.1 - KNX-Interface @ 192.168.2.3:1234"
async def test_payload_reader_send_timeout(self, logger_warning_mock): """Test payload reader: timeout while waiting for response.""" xknx = XKNX() destination_address = IndividualAddress("1.2.3") request_payload = MemoryRead(0xAABB, 3) payload_reader = PayloadReader(xknx, destination_address, timeout_in_seconds=0) payload = await payload_reader.send(request_payload, response_class=MemoryResponse) # No response received. assert payload is None # Warning was logged. logger_warning_mock.assert_called_once_with( "Error: KNX bus did not respond in time (%s secs) to payload request for: %s", 0.0, IndividualAddress("1.2.3"), )