sphero_pwn 0.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,3 @@
1
+ # Namespace for all the commands.
2
+ module SpheroPwn::Commands
3
+ end
@@ -0,0 +1,44 @@
1
+ # Asks the robot about the versions of its software stack components.
2
+ class SpheroPwn::Commands::GetVersions < SpheroPwn::Command
3
+ def initialize
4
+ super 0x00, 0x02, nil
5
+ end
6
+
7
+ # @see {SpheroPwn::Command#response_class}
8
+ def response_class
9
+ SpheroPwn::Commands::GetVersions::Response
10
+ end
11
+ end
12
+
13
+ # The versions of a robot's software stack.
14
+ class SpheroPwn::Commands::GetVersions::Response < SpheroPwn::Response
15
+ # @return {Hash<Symbol, Number>} the software versions of the components in
16
+ # the robot's software stack
17
+ attr_reader :versions
18
+
19
+ # @see {SpheroPwn::Response#initialize}
20
+ def initialize(code_byte, sequence_byte, data_bytes)
21
+ super
22
+
23
+ @versions = {}
24
+ response_version = data_bytes[0]
25
+ if response_version >= 1
26
+ @versions.merge! model: data_bytes[1], hardware: data_bytes[2],
27
+ sphero_app: { version: data_bytes[3], revision: data_bytes[4] },
28
+ bootloader: self.class.parse_packed_nibble(data_bytes[5]),
29
+ basic: self.class.parse_packed_nibble(data_bytes[6]),
30
+ macros: self.class.parse_packed_nibble(data_bytes[7])
31
+ end
32
+ if response_version >= 2
33
+ @versions.merge! api: { major: data_bytes[8], minor: data_bytes[9] }
34
+ end
35
+ end
36
+
37
+ # Decodes a version from packed nibble format.
38
+ #
39
+ # @param {Number} byte the byte value packing the version
40
+ # @return {Hash<Symbol, Number>} maps :major and :minor to version numbers
41
+ def self.parse_packed_nibble(byte)
42
+ { major: (byte >> 4), minor: (byte & 0x0F) }
43
+ end
44
+ end
@@ -0,0 +1,16 @@
1
+ # Asks the robot to send ASCII diagnostic data.
2
+ class SpheroPwn::Commands::L1Diagnostics < SpheroPwn::Command
3
+ def initialize
4
+ super 0x00, 0x40, nil
5
+ end
6
+
7
+ # @see {SpheroPwn::Command#response_class}
8
+ def response_class
9
+ SpheroPwn::Commands::L1Diagnostics::Response
10
+ end
11
+ end
12
+
13
+ # The response to an L1 diagnostics command.
14
+ class SpheroPwn::Commands::L1Diagnostics::Response < SpheroPwn::Response
15
+ end
16
+
@@ -0,0 +1,15 @@
1
+ # Asks the robot to echo this message.
2
+ class SpheroPwn::Commands::Ping < SpheroPwn::Command
3
+ def initialize
4
+ super 0x00, 0x01, nil
5
+ end
6
+
7
+ # @see {SpheroPwn::Command#response_class}
8
+ def response_class
9
+ SpheroPwn::Commands::Ping::Response
10
+ end
11
+ end
12
+
13
+ # The response to an echo command.
14
+ class SpheroPwn::Commands::Ping::Response < SpheroPwn::Response
15
+ end
@@ -0,0 +1,81 @@
1
+ # Implements the Channel API using data from a file.
2
+ class SpheroPwn::ReplayChannel
3
+ #
4
+ #
5
+ # @param {String} recording_path the file storing the recording
6
+ def initialize(recording_path)
7
+ @file = File.open recording_path, 'rt'
8
+ @lines = @file.read.split("\n").map do |line|
9
+ tokens = line.split ' '
10
+
11
+ tokens.map! do |token|
12
+ case token
13
+ when '<'
14
+ :recv
15
+ when '>'
16
+ :send
17
+ else
18
+ token.to_i 16
19
+ end
20
+ end
21
+ end
22
+
23
+ @line_index = 0
24
+ @byte_index = 0
25
+ end
26
+
27
+ # @see {Channel#recv_bytes}
28
+ def recv_bytes(count)
29
+ if @lines[@line_index].nil? || @lines[@line_index].first != :recv
30
+ raise RuntimeError, "Received data at an unexpected time!"
31
+ end
32
+
33
+ line_bytes = @lines[@line_index].length - 1
34
+ if @byte_index + count > line_bytes
35
+ raise ArgumentError, "Attempted to receive #{count} bytes, but only " +
36
+ "#{line_bytes - @byte_index} are available"
37
+ end
38
+
39
+ data = @lines[@line_index][@byte_index + 1, count].pack('C*')
40
+ @byte_index += count
41
+
42
+ if @byte_index == line_bytes
43
+ @byte_index = 0
44
+ @line_index += 1
45
+ end
46
+
47
+ data
48
+ end
49
+
50
+ # @see {Channel#send_bytes}
51
+ def send_bytes(bytes)
52
+ if @lines[@line_index] && @lines[@line_index].first == :recv
53
+ line_bytes = @lines[@line_index].length - 1
54
+ raise RuntimeError, "Sent data before receiving " +
55
+ "#{line_bytes - @byte_index} of #{line_bytes} bytes!"
56
+ end
57
+
58
+ if @lines[@line_index].nil? || @lines[@line_index].first != :send
59
+ raise RuntimeError, "Sent data at an unexpected time!"
60
+ end
61
+
62
+ expected = @lines[@line_index][1..-1]
63
+ data = bytes.unpack 'C*'
64
+ if data != expected
65
+ raise ArgumentError, "Incorrect bytes sent! " +
66
+ "Expected: #{expected.inspect} Got: #{data.inspect}"
67
+ end
68
+ @line_index += 1
69
+ self
70
+ end
71
+
72
+ # @see {Channel#close}
73
+ def close
74
+ if @line_index != @lines.length
75
+ ops_left = @lines.length - @line_index
76
+ raise RuntimeError, "Closed before performing #{ops_left} operations!"
77
+ end
78
+
79
+ self
80
+ end
81
+ end
@@ -0,0 +1,47 @@
1
+ # Superclass for all messages sent from the robot in response to commands.
2
+ class SpheroPwn::Response
3
+ # @return {Symbol} the command's response
4
+ attr_reader :code
5
+
6
+ # @return {Number} the sequence number matching the response to its command
7
+ attr_reader :sequence
8
+
9
+ # @return {Array<Number>} the additional payload bytes in the response; this
10
+ # array is frozen
11
+ attr_reader :data_bytes
12
+
13
+ # Parses a response to a command.
14
+ #
15
+ # @param {Number} code_byte the response code number
16
+ # @param {Number} sequence_byte the sequence number matching the response to
17
+ # its command
18
+ # @param {Array<Number>} data_bytes the additional response payload; can be
19
+ # empty, cannot be nil; the constructor takes ownership of the array and
20
+ # freezes it
21
+ def initialize(code_byte, sequence_byte, data_bytes)
22
+ @code = RESPONSE_CODES[code_byte] || :unknown
23
+ @sequence = sequence_byte
24
+ @data_bytes = data_bytes.freeze
25
+ end
26
+
27
+ # @return {Hash<Integer, Symbol>} maps error codes to symbols
28
+ RESPONSE_CODES = {
29
+ 0x00 => :ok, # Command succeeded.
30
+ 0x01 => :generic_error, # General, non-specific error.
31
+ 0x02 => :bad_checksum, # Received checksum failure.
32
+ 0x03 => :got_fragment, # Received command fragment.
33
+ 0x04 => :bad_command, # Unknown command ID.
34
+ 0x05 => :unsupported, # Command currently unsupported.
35
+ 0x06 => :bad_message, # Bad message format.
36
+ 0x07 => :invalid_param, # Parameter value(s) invalid.
37
+ 0x08 => :exec_failure, # Failed to execute command.
38
+ 0x09 => :bad_device, # Unknown Device ID.
39
+ 0x0A => :ram_busy, # Generic RAM access needed but it is busy.
40
+ 0x0B => :bad_password, # Supplied password incorrect.
41
+ 0x31 => :low_battery, # Voltage too low for reflash operation.
42
+ 0x32 => :bad_page, # Illegal page number provided.
43
+ 0x33 => :flash_fail, # Page did not reprogram correctly.
44
+ 0x34 => :main_app_corrupt, # Main Application corrupt.
45
+ 0x35 => :timed_out, # Msg state machine timed out.
46
+ }.freeze
47
+ end
@@ -0,0 +1,160 @@
1
+ require 'thread'
2
+
3
+ # A communication session with a robot.
4
+ class SpheroPwn::Session
5
+ # @param {Channel} channel the byte-level communication channel with the
6
+ # robot
7
+ def initialize(channel)
8
+ @channel = channel
9
+
10
+
11
+ # Must be acquired to change any of the values below.
12
+ @sequence_lock = Mutex.new
13
+ # Maps sequence numbers to responses expected from the server.
14
+ @pending_responses = {}
15
+ # Sweeps the space of valid sequence numbers.
16
+ @last_sequence = 0
17
+ end
18
+
19
+
20
+ # Terminates this session and closes the underlying communication channel.
21
+ #
22
+ # @return {Session} self
23
+ def close
24
+ @channel.close
25
+ self
26
+ end
27
+
28
+ # @param {Command} command the command to be sent
29
+ # @return {Session} self
30
+ def send_command(command)
31
+ sequence = 0
32
+ if command.expects_response?
33
+ @sequence_lock.synchronize do
34
+ sequence = alloc_sequence
35
+ @pending_responses[sequence] = command.response_class
36
+ end
37
+ end
38
+
39
+ bytes = command.to_bytes sequence
40
+ @channel.send_bytes bytes
41
+ self
42
+ end
43
+
44
+ # Reads messages from the robot until a response is received.
45
+ #
46
+ # @return {Response} the response received
47
+ def recv_until_response
48
+ loop do
49
+ message = recv_message
50
+ if message && message.kind_of?(SpheroPwn::Response)
51
+ return message
52
+ end
53
+
54
+ # TODO(pwnall): customizable sleep interval
55
+ sleep 0.05
56
+ end
57
+ end
58
+
59
+ # Reads a message from the robot.
60
+ #
61
+ # This method blocks until a message is available. The method can be called
62
+ # in a loop on a dedicated thread, and will synchronize correctly with
63
+ # {Session#send_command}.
64
+ #
65
+ # @return {Response, Async} the response read from the channel; can be nil if
66
+ # no message was received or if the checksum verification failed
67
+ def recv_message
68
+ return nil if @channel.recv_bytes(1).ord != 0xFF
69
+
70
+ packet_type = @channel.recv_bytes 1
71
+ case packet_type.ord
72
+ when 0xFF
73
+ read_response
74
+ when 0xFE
75
+ read_async_message
76
+ end
77
+ end
78
+
79
+ # Finds an unused sequence number.
80
+ #
81
+ # Sending a command that requires a response allocates a sequence number.
82
+ # Receiving the required response frees up the sequence number.
83
+ #
84
+ # The caller should own the sequence_lock mutex.
85
+ #
86
+ # @return {Number} a sequence number that can be used for a command; the
87
+ # sequence number is not considered to be allocated until the caller
88
+ # inserts it as a key in @pending_commands
89
+ def alloc_sequence
90
+ begin
91
+ @last_sequence = (@last_sequence + 1) & 0xFF
92
+ end while @pending_responses.has_key? @last_sequence
93
+ @last_sequence
94
+ end
95
+ private :alloc_sequence
96
+
97
+ # Reads the response to a command from the channel.
98
+ #
99
+ # This assumes that the start-of-packet was already read and indicates a
100
+ # command response.
101
+ #
102
+ # @return {Response} the parsed response
103
+ def read_response
104
+ header_bytes = @channel.recv_bytes(3).unpack('C*')
105
+ response_code, sequence, data_length = *header_bytes
106
+
107
+ # It may seem that it'd be better to look up the sequence number and bail
108
+ # early if we don't find it. However, in order to avoid misleading error
109
+ # messages, we don't want to touch anything in the message until we know
110
+ # that the checksum is valid.
111
+ data_bytes = @channel.recv_bytes(data_length).unpack('C*')
112
+ checksum = data_bytes.pop
113
+ unless self.class.valid_checksum?(header_bytes, data_bytes, checksum)
114
+ return nil
115
+ end
116
+
117
+ klass = @sequence_lock.synchronize { @pending_responses.delete sequence }
118
+ return nil if klass.nil?
119
+
120
+ klass.new response_code, sequence, data_bytes
121
+ end
122
+ private :read_response
123
+
124
+ # Reads an asynchronous message from the channel.
125
+ #
126
+ # This assumes that the start-of-packet was already read and indicates an
127
+ # asynchronous message.
128
+ #
129
+ # @return {Response} the parsed response
130
+ def read_async_message
131
+ header_bytes = @channel.recv_bytes(3).unpack('C*')
132
+ class_id, length_msb, length_lsb = *header_bytes
133
+ data_length = (length_msb << 8) | length_lsb
134
+
135
+ # It may seem that it'd be better to look up the sequence number and bail
136
+ # early if we don't find it. However, in order to avoid misleading error
137
+ # messages, we don't want to touch anything in the message until we know
138
+ # that the checksum is valid.
139
+ data_bytes = @channel.recv_bytes(data_length).unpack('C*')
140
+ checksum = data_bytes.pop
141
+ unless self.class.valid_checksum?(header_bytes, data_bytes, checksum)
142
+ return nil
143
+ end
144
+
145
+ SpheroPwn::Asyncs.create class_id, data_bytes
146
+ end
147
+ private :read_async_message
148
+
149
+ # Checks if a message's checksum matches its contents.
150
+ #
151
+ # @param {Array<Number>} header_bytes the header semantics differ between
152
+ # command responses and async messages, but both have 3 bytes
153
+ # @param {Array<Number>} data_bytes
154
+ def self.valid_checksum?(header_bytes, data_bytes, checksum)
155
+ sum = 0
156
+ header_bytes.each { |byte| sum += byte }
157
+ data_bytes.each { |byte| sum += byte }
158
+ checksum == ((sum & 0xFF) ^ 0xFF)
159
+ end
160
+ end
@@ -0,0 +1,18 @@
1
+ module SpheroPwn
2
+ # Intelligently toggles between recording and replaying test data.
3
+ #
4
+ # @param {String} rfconn_path the path to the device file connecting to the
5
+ # robot's Bluetooth RFCONN service
6
+ # @param {String} recording_path the file storing the recording
7
+ # @return {Channel} if the recording file at the given path exists, the
8
+ # return value is a {ReplayChannel} that uses the file; otherwise, the
9
+ # return value is a {ChannelRecorder} that creates the recording
10
+ def self.new_test_channel(rfconn_path, recording_path)
11
+ if File.exist? recording_path
12
+ ReplayChannel.new recording_path
13
+ else
14
+ channel = Channel.new rfconn_path
15
+ ChannelRecorder.new channel, recording_path
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,102 @@
1
+ require_relative './helper.rb'
2
+
3
+ require 'stringio'
4
+
5
+ describe SpheroPwn::ChannelRecorder do
6
+ describe 'with a channel that always responds' do
7
+ before do
8
+ @channel = MiniTest::Mock.new
9
+ @channel.expect :send_bytes, @channel, ["Hello\n"]
10
+ @channel.expect :recv_bytes, 'Hello', [5]
11
+ @channel.expect :recv_bytes, " too!\n", [6]
12
+ @channel.expect :send_bytes, @channel, ['OK']
13
+ @channel.expect :send_bytes, @channel, ["Bye\n"]
14
+ @channel.expect :recv_bytes, 'Bye', [3]
15
+ @channel.expect :recv_bytes, " bye\n", [5]
16
+ @channel.expect :close, @channel, []
17
+ end
18
+
19
+ after { @channel.verify }
20
+
21
+ it 'proxies calls correctly' do
22
+ output = StringIO.new
23
+ recorder = File.stub :open, output do
24
+ SpheroPwn::ChannelRecorder.new @channel, '/tmp/stubbed'
25
+ end
26
+
27
+ assert_equal recorder, recorder.send_bytes("Hello\n")
28
+ assert_equal 'Hello', recorder.recv_bytes(5)
29
+ assert_equal " too!\n", recorder.recv_bytes(6)
30
+ assert_equal recorder, recorder.send_bytes('OK')
31
+ assert_equal recorder, recorder.send_bytes("Bye\n")
32
+ assert_equal 'Bye', recorder.recv_bytes(3)
33
+ assert_equal " bye\n", recorder.recv_bytes(5)
34
+ assert_equal recorder, recorder.close
35
+ end
36
+
37
+ it 'produces the output file correctly' do
38
+ output = StringIO.new
39
+ recorder = File.stub :open, output do
40
+ SpheroPwn::ChannelRecorder.new @channel, '/tmp/stubbed'
41
+ end
42
+
43
+ recorder.send_bytes "Hello\n"
44
+ recorder.recv_bytes 5
45
+ recorder.recv_bytes 6
46
+ recorder.send_bytes 'OK'
47
+ recorder.send_bytes "Bye\n"
48
+ recorder.recv_bytes 3
49
+ recorder.recv_bytes 5
50
+ recorder.close
51
+
52
+ golden = <<END_STRING
53
+ > 48 65 6C 6C 6F 0A
54
+ < 48 65 6C 6C 6F 20 74 6F 6F 21 0A
55
+ > 4F 4B
56
+ > 42 79 65 0A
57
+ < 42 79 65 20 62 79 65 0A
58
+ END_STRING
59
+ assert_equal golden, output.string
60
+ end
61
+ end
62
+
63
+ describe 'with a channel that sometimes returns nils' do
64
+ before do
65
+ @channel = MiniTest::Mock.new
66
+ @channel.expect :send_bytes, @channel, ["Hello\n"]
67
+ @channel.expect :recv_bytes, nil, [5]
68
+ @channel.expect :recv_bytes, "Hello too!\n", [11]
69
+ @channel.expect :close, @channel, []
70
+ end
71
+
72
+ it 'proxies calls correctly' do
73
+ output = StringIO.new
74
+ recorder = File.stub :open, output do
75
+ SpheroPwn::ChannelRecorder.new @channel, '/tmp/stubbed'
76
+ end
77
+
78
+ assert_equal recorder, recorder.send_bytes("Hello\n")
79
+ assert_equal nil, recorder.recv_bytes(5)
80
+ assert_equal "Hello too!\n", recorder.recv_bytes(11)
81
+ assert_equal recorder, recorder.close
82
+ end
83
+
84
+ it 'produces the output file correctly' do
85
+ output = StringIO.new
86
+ recorder = File.stub :open, output do
87
+ SpheroPwn::ChannelRecorder.new @channel, '/tmp/stubbed'
88
+ end
89
+
90
+ recorder.send_bytes "Hello\n"
91
+ recorder.recv_bytes 5
92
+ recorder.recv_bytes 11
93
+ recorder.close
94
+
95
+ golden = <<END_STRING
96
+ > 48 65 6C 6C 6F 0A
97
+ < 48 65 6C 6C 6F 20 74 6F 6F 21 0A
98
+ END_STRING
99
+ assert_equal golden, output.string
100
+ end
101
+ end
102
+ end