diff --git a/shard.yml b/shard.yml index f5fbdf6..7110349 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: knx -version: 1.1.1 +version: 1.2.0 crystal: ">= 0.36.1" dependencies: diff --git a/spec/knx_spec.cr b/spec/knx_spec.cr index 0988851..c0022b9 100644 --- a/spec/knx_spec.cr +++ b/spec/knx_spec.cr @@ -25,22 +25,57 @@ describe "knx protocol helper" do it "should generate single bit action requests" do datagram = knx.action("1/2/0", false) - datagram.to_slice.should eq(Bytes[6, 16, 5, 48, 0, 17, 41, 0, 188, 224, 0, 1, 10, 0, 1, 0, 128]) + datagram.to_slice.should eq(Bytes[6, 16, 5, 48, 0, 17, 41, 0, 188, 224, 0, 0, 10, 0, 1, 0, 128]) datagram = knx.action("1/2/0", true) - datagram.to_slice.should eq(Bytes[6, 16, 5, 48, 0, 17, 41, 0, 188, 224, 0, 1, 10, 0, 1, 0, 129]) + datagram.to_slice.should eq(Bytes[6, 16, 5, 48, 0, 17, 41, 0, 188, 224, 0, 0, 10, 0, 1, 0, 129]) end it "should generate byte action requests" do datagram = knx.action("1/2/0", 20) - datagram.to_slice.should eq(Bytes[6, 16, 5, 48, 0, 17, 41, 0, 188, 224, 0, 1, 10, 0, 1, 0, 148]) + datagram.to_slice.should eq(Bytes[6, 16, 5, 48, 0, 17, 41, 0, 188, 224, 0, 0, 10, 0, 1, 0, 148]) datagram = knx.action("1/2/0", 240) - datagram.to_slice.should eq(Bytes[6, 16, 5, 48, 0, 18, 41, 0, 188, 224, 0, 1, 10, 0, 1, 0, 128, 240]) + datagram.to_slice.should eq(Bytes[6, 16, 5, 48, 0, 18, 41, 0, 188, 224, 0, 0, 10, 0, 2, 0, 128, 240]) end it "should generate status requests" do datagram = knx.status("1/2/1") - datagram.to_slice.should eq(Bytes[6, 16, 5, 48, 0, 17, 41, 0, 188, 224, 0, 1, 10, 1, 0, 0, 0]) + datagram.to_slice.should eq(Bytes[6, 16, 5, 48, 0, 17, 17, 0, 188, 224, 0, 0, 10, 1, 1, 0, 0]) + end + + # examples from + # https://github.com/uptimedk/knxnet_ip/blob/master/test/knxnet_ip/telegram_test.exs + + it "should encode / decode a Group Read" do + datagram = knx.status("0/0/3", source: "1.0.3", msg_code: :data_indicator) + cemi_raw = "2900bce010030003010000".hexbytes + datagram.cemi.to_slice.should eq(cemi_raw) + + input = IO::Memory.new(cemi_raw) + cemi = input.read_bytes(KNX::CEMI) + cemi.to_slice.should eq cemi_raw + end + + it "should encode / decode a Group Write" do + datagram = knx.action("0/0/3", 0x1917, source: "1.1.1") + cemi_raw = "2900bce0110100030300801917".hexbytes + datagram.cemi.to_slice.should eq(cemi_raw) + + input = IO::Memory.new(cemi_raw) + cemi = input.read_bytes(KNX::CEMI) + cemi.to_slice.should eq cemi_raw + end + + it "should encode / decode a Group Respnse" do + # source: "1.1.4", + # destination: "0/0/2", + # service: :group_response, + # value: <<0x41, 0x46, 0x8F, 0x5C>> + + cemi_raw = "2900bce01104000205004041468F5C".hexbytes + input = IO::Memory.new(cemi_raw) + cemi = input.read_bytes(KNX::CEMI) + cemi.to_slice.should eq cemi_raw end end diff --git a/spec/routing_indication_spec.cr b/spec/routing_indication_spec.cr deleted file mode 100644 index 793ee90..0000000 --- a/spec/routing_indication_spec.cr +++ /dev/null @@ -1,22 +0,0 @@ -require "./spec_helper" - -describe KNX::TunnelRequest do - it "should parse a routing request" do - raw = Bytes[0x06, 0x10, 0x05, 0x30, 0x00, 0x12, 0x29, 0x00, - 0xbc, 0xd0, 0x12, 0x02, 0x01, 0x51, 0x02, 0x00, - 0x40, 0xf0] - input = IO::Memory.new(raw.clone) - req = input.read_bytes(KNX::IndicationRequest) - req.header.request_type.should eq(KNX::RequestTypes::RoutingIndication) - req.cemi.is_group_address.should eq(true) - req.source_address.should eq("1.2.2") - req.destination_address.should eq("0/1/81") - - req.cemi.data_length.should eq(2) - req.payload.should eq(Bytes[0, 0xf0]) - - output = IO::Memory.new - output.write_bytes req - output.to_slice.should eq(raw) - end -end diff --git a/spec/tunnel_client_spec.cr b/spec/tunnel_client_spec.cr index 02de682..b6d8829 100644 --- a/spec/tunnel_client_spec.cr +++ b/spec/tunnel_client_spec.cr @@ -108,4 +108,49 @@ describe KNX::TunnelClient do client.queue_size.should eq 0 end + + it "should work with helper methods" do + client = KNX::TunnelClient.new( + Socket::IPAddress.new("10.9.78.59", 48907), + knx: ::KNX.new( + priority: :alarm, + broadcast: true, + no_repeat: true + ) + ) + client.connected?.should eq false + + is_connected = nil + last_error = nil + last_trans = nil + last_cemi = nil + + client.on_state_change do |connected, error| + is_connected = connected + last_error = error + end + client.on_transmit { |bytes| last_trans = bytes } + client.on_message { |cemi| last_cemi = cemi } + + # check connection flow + client.connect + last_trans.should eq "06100205001a08010a094e3bbf0b08010a094e3bbf0b04040200".hexbytes + client.process "0610020600143d0008010a094e510e5704041103".hexbytes + + is_connected.should eq true + last_error.should eq KNX::ConnectionError::NoError + client.waiting?.should eq false + + # make some requests + client.action("0/0/2", true) + last_trans.try(&.hexstring).should eq "061004200015043d00002900b4e000000002010081" + client.process "0610020800083d00".hexbytes + + client.action("0/0/2", 2) + last_trans.try(&.hexstring).should eq "061004200015043d01002900b4e000000002010082" + client.process "0610020800083d00".hexbytes + + client.status("0/0/2") + last_trans.try(&.hexstring).should eq "061004200015043d02001100b4e000000002010000" + end end diff --git a/spec/tunnel_spec.cr b/spec/tunnel_spec.cr index d335017..ac385df 100644 --- a/spec/tunnel_spec.cr +++ b/spec/tunnel_spec.cr @@ -12,7 +12,7 @@ describe KNX::TunnelRequest do req.channel_id.should eq(1) req.sequence.should eq(23) req.cemi.is_group_address.should eq(true) - req.destination_address.should eq("9/0/8") + req.destination_address.to_s.should eq("9/0/8") raw = "061004200015043d00001100b4e000000002010000".hexbytes input = IO::Memory.new(raw) diff --git a/src/knx.cr b/src/knx.cr index 9586e91..4eff730 100644 --- a/src/knx.cr +++ b/src/knx.cr @@ -10,7 +10,6 @@ require "./knx/connection/connect_request" require "./knx/connection/connect_state_request" require "./knx/connection/disconnect_request" require "./knx/connection/tunnel_request" -require "./knx/routing/indication_request" # Discovery and negotiation: http://knxer.net/?p=78 @@ -21,29 +20,30 @@ class KNX property? no_repeat : Bool property? broadcast : Bool property hop_count : UInt8 - property msg_code : MsgCode property cmac_key : Bytes? + property source : String def initialize( @priority = Priority::LOW, @no_repeat = true, @broadcast = true, @hop_count = 6_u8, - @msg_code = MsgCode::DataIndicator, @two_level_group = false, - @cmac_key = nil + @cmac_key = nil, + @source = "0.0.0" ) end def action( address : String, data, - msg_code : MsgCode = @msg_code, + msg_code : MsgCode = MsgCode::DataIndicator, no_repeat : Bool = @no_repeat, broadcast : Bool = @broadcast, priority : Priority = @priority, hop_count : UInt8 = @hop_count, - request_type : RequestTypes = RequestTypes::RoutingIndication + request_type : RequestTypes = RequestTypes::RoutingIndication, + source : String = @source ) : ActionDatagram raw = case data when Bool @@ -76,18 +76,20 @@ class KNX broadcast: broadcast, priority: priority, hop_count: hop_count, - request_type: request_type + request_type: request_type, + source: source ) end def status( address : String, - msg_code : MsgCode = @msg_code, + msg_code : MsgCode = MsgCode::DataRequest, no_repeat : Bool = @no_repeat, broadcast : Bool = @broadcast, priority : Priority = @priority, hop_count : UInt8 = @hop_count, - request_type : RequestTypes = RequestTypes::RoutingIndication + request_type : RequestTypes = RequestTypes::RoutingIndication, + source : String = @source ) StatusDatagram.new( address, @@ -96,7 +98,8 @@ class KNX broadcast: broadcast, priority: priority, hop_count: hop_count, - request_type: request_type + request_type: request_type, + source: source ) end diff --git a/src/knx/address.cr b/src/knx/address.cr index 352917a..91eddee 100644 --- a/src/knx/address.cr +++ b/src/knx/address.cr @@ -36,20 +36,37 @@ class KNX abstract class Address < BinData endian :big - def self.parse(input) - case input - when Int - io = IO::Memory.new(2) - io.write_bytes input.to_u16, IO::ByteFormat::BigEndian - io.rewind - io.read_bytes(self, IO::ByteFormat::BigEndian) - when String - addr = parse_friendly(input) - raise "address parsing failed" unless addr - addr + def self.parse(input : Int) + io = IO::Memory.new(2) + io.write_bytes input.to_u16, IO::ByteFormat::BigEndian + io.rewind + io.read_bytes(self, IO::ByteFormat::BigEndian) + end + + def self.parse(bytes : Bytes) + io = IO::Memory.new(bytes) + io.read_bytes(self) + end + + def self.parse(io : IO) + io.read_bytes(self) + end + + def self.parse(address : String) : Address + count = address.count('/') + case count + when 2 + GroupAddress.parse_friendly(address) + when 1 + GroupAddress2Level.parse_friendly(address) else - io = IO::Memory.new(input.to_slice) - io.read_bytes(self) + IndividualAddress.parse_friendly(address) + end + end + + macro inherited + def self.parse(address : String) : self + parse_friendly address end end diff --git a/src/knx/cemi.cr b/src/knx/cemi.cr index d5e3057..a26c77d 100644 --- a/src/knx/cemi.cr +++ b/src/knx/cemi.cr @@ -1,24 +1,26 @@ +require "./address" + class KNX # APCI type enum ActionType - GroupRead = 0 - GroupResp = 1 - GroupWrite = 2 + GroupRead = 0 + GroupResp = 0x40 + GroupWrite = 0x80 IndividualWrite = 0x0C0 IndividualRead = 0x100 IndividualResp = 0x140 - AdcRead = 6 + AdcRead = 0x180 AdcResp = 0x1C0 SysNetParamRead = 0x1C4 SysNetParamResp = 0x1C9 SysNetParamWrite = 0x1CA - MemoryRead = 0x020 - MemoryResp = 0x024 - MemoryWrite = 0x028 + MemoryRead = 0x200 + MemoryResp = 0x240 + MemoryWrite = 0x280 UserMemoryRead = 0x2C0 UserMemoryResp = 0x2C1 @@ -262,10 +264,21 @@ class KNX end # When sending, setting the source address to 0 allows the router to configure - field source_address : Bytes, length: ->{ 2 } - field destination_address : Bytes, length: ->{ 2 } + field source_address : IndividualAddress = IndividualAddress.new + field destination_raw : Bytes, length: ->{ 2 } + + property two_level_group : Bool = false + property destination_address : Address do + if !is_group_address + IndividualAddress.parse(destination_raw) + elsif two_level_group + GroupAddress2Level.parse(destination_raw) + else + GroupAddress.parse(destination_raw) + end + end - field data_length : UInt8 + field data_length : UInt8, value: ->{ data_ext.size + 1 } # In the Common EMI frame, the APDU payload is defined as follows: @@ -307,33 +320,45 @@ class KNX bits 2, tpci : TpciType = TpciType::UnnumberedData bits 4, :tpci_seq_num # Sequence number when tpci is sequenced bits 4, :apci # application protocol control information (What we trying to do: Read, write, respond etc) - bits 6, :data # Or the tail end of APCI depending on the message type + bits 6, :data_short # Or the tail end of APCI depending on the message type + end + + field data_ext : Bytes, length: ->{ data_length > 1 ? data_length - 1 : 0 } + + property action_type : ActionType = ActionType::GroupRead + property data : Bytes = Bytes.new(0) + + before_serialize do + value = action_type.to_i + self.apci = (value >> 6).to_u8 + + self.destination_raw = destination_address.to_slice + + if value & 0x111111 > 0 + # the action bleeds into the data_short field + self.data_short = (value & 0b111111).to_u8 + elsif data.size == 1 && data[0] <= 0b111111 + # we can store the 6 bits if we wanted + self.data_short = data[0] + self.data_ext = Bytes.new(0) + else + # we use the extended data + self.data_short = 0_u8 + self.data_ext = data + end end - # Applies 2 byte APCI value where required - # - # @param val [Symbol, Integer] the value or symbol representing the APCI value - # @return [true, false] returns true if data is available for storage - def apply_apci(action : ActionType | Int, data : Bytes? = nil) : Bool - value = action.to_i - - if value > 15 - # (value >> 6) & 0b1111 - self.apci = value.bits(6...10).to_u8 - self.data = (value & 0b111111).to_u8 - false + after_deserialize do + # anything bigger than an Individual Resp (0b0101) does not use data_short + # any anything using extended data also is not using it + if data_ext.size > 0 || apci > 0b0101_u8 + # simple to determine the packet type where data ext is used + self.data = data_ext + self.action_type = ActionType.from_value((apci.to_i << 6) | data_short.to_i) else - self.apci = value.to_u8 - if data && data[0]? && data[0] <= 0b111111 - self.data = data[0] - true - else - self.data = 0_u8 - false - end + self.data = Bytes[data_short] + self.action_type = ActionType.from_value(apci.to_i << 6) end - rescue e - raise ArgumentError.new("Bad apci value: #{data}") end end end diff --git a/src/knx/connection/tunnel_request.cr b/src/knx/connection/tunnel_request.cr index d2bfe94..d229eea 100644 --- a/src/knx/connection/tunnel_request.cr +++ b/src/knx/connection/tunnel_request.cr @@ -32,17 +32,8 @@ class KNX request end - def source_address : String - KNX::IndividualAddress.parse(@cemi.source_address).to_s - end - - def destination_address : String - if @cemi.is_group_address - KNX::GroupAddress.parse(@cemi.destination_address).to_s - else - KNX::IndividualAddress.parse(@cemi.destination_address).to_s - end - end + delegate :source_address, to: @cemi + delegate :destination_address, to: @cemi end class TunnelResponse < BinData diff --git a/src/knx/datagram.cr b/src/knx/datagram.cr index 209669a..d1e3881 100644 --- a/src/knx/datagram.cr +++ b/src/knx/datagram.cr @@ -4,39 +4,28 @@ class KNX class DatagramBuilder property header : Header property cemi : CEMI - property source_address : Address - property destination_address : Address - property data : IO::Memory = IO::Memory.new(0) - property action_type : ActionType = ActionType::GroupRead - def to_slice - raw_data = @data.to_slice - write_data = if @cemi.apply_apci(@action_type, raw_data) - @cemi.data_length = raw_data.size.to_u8 - - if raw_data.size > 1 - raw_data[1..-1] - else - Bytes.new(0) - end - elsif raw_data.size > 0 - @cemi.data_length = raw_data.size.to_u8 - raw_data - else - @cemi.data_length = 0_u8 - Bytes.new(0) - end - - @cemi.source_address = @source_address.to_slice - @cemi.destination_address = @destination_address.to_slice - - # 17 == header + cemi - @header.request_length = (write_data.size + 17).to_u16 + delegate :data, to: @cemi + + delegate :action_type, to: @cemi + delegate :action_type=, to: @cemi + + delegate :source_address, to: @cemi + delegate :source_address=, to: @cemi + + delegate :destination_address, to: @cemi + delegate :destination_address=, to: @cemi + def to_slice io = IO::Memory.new io.write_bytes @header - io.write_bytes @cemi - io.write write_data + io.write @cemi.to_slice + + # inject the request length + @header.request_length = length = io.size.to_u16 + io.pos = 4 + io.write_bytes length, IO::ByteFormat::BigEndian + io.to_slice end @@ -51,41 +40,25 @@ class KNX broadcast : Bool = false, priority : Priority = Priority::LOW, hop_count : UInt8 = 7, - request_type : RequestTypes = RequestTypes::RoutingIndication + request_type : RequestTypes = RequestTypes::RoutingIndication, + source : String = "0.0.0", ) - address = parse(address) - @cemi = CEMI.new + @cemi.destination_address = Address.parse(address) + @cemi.source_address = IndividualAddress.parse_friendly(source) + @cemi.msg_code = msg_code @cemi.is_standard_frame = true @cemi.no_repeat = no_repeat @cemi.broadcast = broadcast @cemi.priority = priority - @cemi.is_group_address = address.group? + @cemi.is_group_address = destination_address.group? @cemi.hop_count = hop_count @header = Header.new @header.version = 0x10_u8 @header.request_type = request_type - - @source_address = IndividualAddress.parse_friendly("0.0.1") - @destination_address = address - - @cemi.source_address = @source_address.to_slice - @cemi.destination_address = @destination_address.to_slice - end - - protected def parse(address) : Address - count = address.count('/') - case count - when 2 - GroupAddress.parse_friendly(address) - when 1 - GroupAddress2Level.parse_friendly(address) - else - IndividualAddress.parse_friendly(address) - end end end @@ -94,14 +67,9 @@ class KNX super(address, **options) # Set the protocol control information - @action_type = @destination_address.group? ? ActionType::GroupWrite : ActionType::IndividualWrite - @cemi.apply_apci(@action_type, data_array) + @cemi.action_type = destination_address.group? ? ActionType::GroupWrite : ActionType::IndividualWrite + @cemi.data = data_array @cemi.tpci = TpciType::UnnumberedData - - if data_array.size > 0 - @cemi.data_length = data_array.size.to_u8 - @data = IO::Memory.new(data_array) - end end end @@ -110,8 +78,8 @@ class KNX super(address, **options) # Set the protocol control information - @action_type = @destination_address.group? ? ActionType::GroupRead : ActionType::IndividualRead - @cemi.apply_apci(@action_type) + @action_type = destination_address.group? ? ActionType::GroupRead : ActionType::IndividualRead + @cemi.data = Bytes.new(0) @cemi.tpci = TpciType::UnnumberedData end end @@ -120,32 +88,6 @@ class KNX def initialize(io : IO, two_level_group = false) @header = io.read_bytes Header @cemi = io.read_bytes CEMI - - # Header == 6 bytes - # cemi min is == 11 bytes - data_length = @header.request_length - 17 - @cemi.info_length - if @cemi.data_length > data_length - @data.write_byte @cemi.data - @action_type = ActionType.from_value(@cemi.apci) - else - acpi = @cemi.data | (@cemi.apci << 6) - @action_type = ActionType.from_value?(acpi) || ActionType.from_value(@cemi.apci) - end - - bytes = Bytes.new(data_length) - io.read_fully bytes - @data.write bytes - @data.rewind - - @source_address = IndividualAddress.parse(@cemi.source_address) - - if !@cemi.is_group_address - @destination_address = IndividualAddress.parse(@cemi.destination_address) - elsif two_level_group - @destination_address = GroupAddress2Level.parse(@cemi.destination_address) - else - @destination_address = GroupAddress.parse(@cemi.destination_address) - end end end end diff --git a/src/knx/routing/indication_request.cr b/src/knx/routing/indication_request.cr deleted file mode 100644 index bf44083..0000000 --- a/src/knx/routing/indication_request.cr +++ /dev/null @@ -1,87 +0,0 @@ -class KNX - class IndicationRequest < BinData - endian big - - field header : Header = Header.new - field cemi : CEMI = CEMI.new - field extended_bytes : Bytes, length: ->{ header.request_length - 17 - cemi.info_length } - - def payload : Bytes - data_length = @header.request_length - 17 - @cemi.info_length - if @cemi.data_length > data_length - io = IO::Memory.new(@cemi.data_length) - io.write_byte @cemi.data - io.write @extended_bytes - io.to_slice - else - @extended_bytes - end - end - - def apply_apci(action : ActionType | Int, data = nil) - raw_data = data ? data.to_slice : Bytes.new(0) - used_first_byte = @cemi.apply_apci(action, raw_data) - - if used_first_byte - @extended_bytes = if raw_data.size > 1 - raw_data[1..-1] - else - Bytes.new(0) - end - else - @extended_bytes = raw_data - end - - @cemi.data_length = raw_data.size.to_u8 - - nil - end - - def self.new( - address : String, - action : ActionType, - msg_code : MsgCode = MsgCode::DataIndicator, - no_repeat : Bool = false, - broadcast : Bool = false, - priority : Priority = Priority::LOW, - hop_count : UInt8 = 7, - data = Bytes.new(0) - ) - request = IndicationRequest.new - request.header.request_type = RequestTypes::RoutingIndication - - request.cemi.msg_code = msg_code - request.cemi.is_standard_frame = true - request.cemi.no_repeat = no_repeat - request.cemi.broadcast = broadcast - request.cemi.priority = priority - request.cemi.is_group_address = address.group? - request.cemi.hop_count = hop_count - - source_address = IndividualAddress.parse_friendly("0.0.1") - destination_address = Address.parse(address) - request.cemi.source_address = source_address.to_slice - request.cemi.destination_address = destination_address.to_slice - - request.apply_apci(action, data) - - # Header == 6 bytes - # cemi min is == 11 bytes - request.header.request_length = 17 + request.cemi.info_length + request.extended_bytes.size - - request - end - - def source_address : String - KNX::IndividualAddress.parse(@cemi.source_address).to_s - end - - def destination_address : String - if @cemi.is_group_address - KNX::GroupAddress.parse(@cemi.destination_address).to_s - else - KNX::IndividualAddress.parse(@cemi.destination_address).to_s - end - end - end -end diff --git a/src/knx/tunnel_client.cr b/src/knx/tunnel_client.cr index dfafa04..c875200 100644 --- a/src/knx/tunnel_client.cr +++ b/src/knx/tunnel_client.cr @@ -6,7 +6,8 @@ class KNX def initialize( @control : Socket::IPAddress, @timeout : Time::Span = 3.seconds, - @max_retries : Int32 = 5 + @max_retries : Int32 = 5, + @knx : ::KNX = ::KNX.new(broadcast: false, no_repeat: true) ) end @@ -113,6 +114,16 @@ class KNX @on_transmit.try &.call(KNX::DisconnectRequest.new(@channel_id, @control).to_slice) rescue nil end + def action(address : String, data, **options) + datagram = @knx.action(address, data, **options) + request(datagram.cemi) + end + + def status(address : String, **options) + datagram = @knx.status(address, **options) + request(datagram.cemi) + end + # perform an action / query def request(message : KNX::CEMI) raise "not connected" unless connected?