sphero_pwn 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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