-
Notifications
You must be signed in to change notification settings - Fork 0
/
scratch.py
256 lines (231 loc) · 7.32 KB
/
scratch.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
import array
import itertools
import socket
import struct
class ScratchError(Exception): pass
class ScratchConnectionError(ScratchError): pass
class Scratch(object):
prefix_len = 4
broadcast_prefix_len = prefix_len + len('broadcast ')
sensorupdate_prefix_len = prefix_len + len('sensor-update ')
msg_types = set(['broadcast', 'sensor-update'])
def __init__(self, host='localhost', port=42001):
self.host = host
self.port = port
self.socket = None
self.connected = False
self.connect()
def __repr__(self):
return "Scratch(host=%r, port=%r)" % (self.host, self.port)
def _pack(self, msg):
"""
Packages msg according to Scratch message specification (encodes and
appends length prefix to msg). Credit to chalkmarrow from the
scratch.mit.edu forums for the prefix encoding code.
"""
n = len(msg)
a = array.array('c')
a.append(chr((n >> 24) & 0xFF))
a.append(chr((n >> 16) & 0xFF))
a.append(chr((n >> 8) & 0xFF))
a.append(chr(n & 0xFF))
return a.tostring() + msg
def _extract_len(self, prefix):
"""
Extracts the length of a Scratch message from the given message prefix.
"""
return struct.unpack(">L", prefix)[0]
def _get_type(self, s):
"""
Converts a string from Scratch to its proper type in Python. Expects a
string with its delimiting quotes in place. Returns either a string,
int or float.
"""
# TODO: what if the number is bigger than an int or float?
if s.startswith('"') and s.endswith('"'):
return s[1:-1]
elif s.find('.') != -1:
return float(s)
else:
return int(s)
def _escape(self, msg):
"""
Escapes double quotes by adding another double quote as per the Scratch
protocol. Expects a string without its delimiting quotes. Returns a new
escaped string.
"""
escaped = ''
for c in msg:
escaped += c
if c == '"':
escaped += '"'
return escaped
def _unescape(self, msg):
"""
Removes double quotes that were used to escape double quotes. Expects
a string without its delimiting quotes, or a number. Returns a new
unescaped string.
"""
if isinstance(msg, (int, float, long)):
return msg
unescaped = ''
i = 0
while i < len(msg):
unescaped += msg[i]
if msg[i] == '"':
i+=1
i+=1
return unescaped
def _is_msg(self, msg):
"""
Returns True if message is a proper Scratch message, else return False.
"""
if not msg or len(msg) < self.prefix_len:
return False
length = self._extract_len(msg[:self.prefix_len])
msg_type = msg[self.prefix_len:].split(' ', 1)[0]
if length == len(msg[self.prefix_len:]) and msg_type in self.msg_types:
return True
return False
def _parse_broadcast(self, msg):
"""
Given a broacast message, returns the message that was broadcast.
"""
# get message, remove surrounding quotes, and unescape
return self._unescape(self._get_type(msg[self.broadcast_prefix_len:]))
def _parse_sensorupdate(self, msg):
"""
Given a sensor-update message, returns the sensors/variables that were
updated as a dict that maps sensors/variables to their updated values.
"""
update = msg[self.sensorupdate_prefix_len:]
parsed = [] # each element is either a sensor (key) or a sensor value
curr_seg = '' # current segment (i.e. key or value) being formed
numq = 0 # number of double quotes in current segment
for seg in update.split(' ')[:-1]: # last char in update is a space
numq += seg.count('"')
curr_seg += seg
# even number of quotes means we've finished parsing a segment
if numq % 2 == 0:
parsed.append(curr_seg)
curr_seg = ''
numq = 0
else: # segment has a space inside, so add back it in
curr_seg += ' '
unescaped = [self._unescape(self._get_type(x)) for x in parsed]
# combine into a dict using iterators (both elements in the list
# inputted to izip have a reference to the same iterator). even
# elements are keys, odd are values
return dict(itertools.izip(*[iter(unescaped)]*2))
def _parse(self, msg):
"""
Parses a Scratch message and returns a tuple with the first element
as the message type, and the second element as the message payload. The
payload for a 'broadcast' message is a string, and the payload for a
'sensor-update' message is a dict whose keys are variables, and values
are updated variable values. Returns None if msg is not a message.
"""
if not self._is_msg(msg):
return None
msg_type = msg[self.prefix_len:].split(' ')[0]
if msg_type == 'broadcast':
return ('broadcast', self._parse_broadcast(msg))
else:
return ('sensor-update', self._parse_sensorupdate(msg))
def _write(self, data):
"""
Writes string data out to Scratch
"""
total_sent = 0
length = len(data)
while total_sent < length:
try:
sent = self.socket.send(data[total_sent:])
except socket.error as (err, msg):
self.connected = False
raise ScratchError("[Errno %d] %s" % (err, msg))
if sent == 0:
self.connected = False
raise ScratchConnectionError("Connection broken")
total_sent += sent
def _send(self, data):
"""
Sends a message to Scratch
"""
self._write(self._pack(data))
def _read(self, size):
"""
Reads size number of bytes from Scratch and returns data as a string
"""
data = ''
while len(data) < size:
try:
chunk = self.socket.recv(size-len(data))
except socket.error as (err, msg):
self.connected = False
raise ScratchError("[Errno %d] %s" % (err, msg))
if chunk == '':
self.connected = False
raise ScratchConnectionError("Connection broken")
data += chunk
return data
def _recv(self):
"""
Receives and returns a message from Scratch
"""
prefix = self._read(self.prefix_len)
msg = self._read(self._extract_len(prefix))
return prefix + msg
def connect(self):
"""
Connects to Scratch.
"""
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
self.socket.connect((self.host, self.port))
except socket.error as (err, msg):
self.connected = False
raise ScratchError("[Errno %d] %s" % (err, msg))
self.connected = True
def disconnect(self):
"""
Closes connection to Scratch
"""
try: # connection may already be disconnected, so catch exceptions
self.socket.shutdown(socket.SHUT_RDWR) # a proper disconnect
except socket.error:
pass
self.socket.close()
self.connected = False
def sensorupdate(self, data):
"""
Given a dict of sensors and values, updates those sensors with the
values in Scratch.
"""
if not isinstance(data, dict):
raise TypeError('Expected a dict')
msg = 'sensor-update '
for key in data.keys():
msg += '"%s" "%s" ' % (self._escape(str(key)),
self._escape(str(data[key])))
self._send(msg)
def broadcast(self, msg):
"""
Broadcasts msg to Scratch. msg can be a single message or an iterable
(list, tuple, set, generator, etc.) of messages.
"""
if getattr(msg, '__iter__', False): # iterable
for m in msg:
self._send('broadcast "%s"' % self._escape(str(m)))
else: # probably a string or number
self._send('broadcast "%s"' % self._escape(str(msg)))
def receive(self):
"""
Receives broadcasts and sensor updates from Scratch. Returns a tuple
with the first element as the message type and the second element
as the message payload. broadcast messages have a string as payload,
and the sensor-update messages have a dict as payload. Returns None if
message received could not be parsed. Raises exceptions on connection
errors.
"""
return self._parse(self._recv())