|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2019 - 2021 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing the device interface class for BBC micro:bit and |
|
8 Calliope mini boards. |
|
9 """ |
|
10 |
|
11 import os |
|
12 import shutil |
|
13 |
|
14 from PyQt5.QtCore import pyqtSlot, QStandardPaths |
|
15 from PyQt5.QtWidgets import QInputDialog, QLineEdit |
|
16 |
|
17 from .MicroPythonDevices import MicroPythonDevice |
|
18 from .MicroPythonWidget import HAS_QTCHART |
|
19 |
|
20 from E5Gui import E5MessageBox, E5FileDialog |
|
21 from E5Gui.E5Application import e5App |
|
22 |
|
23 import Utilities |
|
24 import Preferences |
|
25 |
|
26 |
|
27 class MicrobitDevice(MicroPythonDevice): |
|
28 """ |
|
29 Class implementing the device for BBC micro:bit and Calliope mini boards. |
|
30 """ |
|
31 def __init__(self, microPythonWidget, deviceType, parent=None): |
|
32 """ |
|
33 Constructor |
|
34 |
|
35 @param microPythonWidget reference to the main MicroPython widget |
|
36 @type MicroPythonWidget |
|
37 @param deviceType type of the device |
|
38 @type str |
|
39 @param parent reference to the parent object |
|
40 @type QObject |
|
41 """ |
|
42 super().__init__( |
|
43 microPythonWidget, deviceType, parent) |
|
44 |
|
45 def setButtons(self): |
|
46 """ |
|
47 Public method to enable the supported action buttons. |
|
48 """ |
|
49 super().setButtons() |
|
50 self.microPython.setActionButtons( |
|
51 run=True, repl=True, files=True, chart=HAS_QTCHART) |
|
52 |
|
53 def forceInterrupt(self): |
|
54 """ |
|
55 Public method to determine the need for an interrupt when opening the |
|
56 serial connection. |
|
57 |
|
58 @return flag indicating an interrupt is needed |
|
59 @rtype bool |
|
60 """ |
|
61 return True |
|
62 |
|
63 def deviceName(self): |
|
64 """ |
|
65 Public method to get the name of the device. |
|
66 |
|
67 @return name of the device |
|
68 @rtype str |
|
69 """ |
|
70 if self.getDeviceType() == "bbc_microbit": |
|
71 # BBC micro:bit |
|
72 return self.tr("BBC micro:bit") |
|
73 else: |
|
74 # Calliope mini |
|
75 return self.tr("Calliope mini") |
|
76 |
|
77 def canStartRepl(self): |
|
78 """ |
|
79 Public method to determine, if a REPL can be started. |
|
80 |
|
81 @return tuple containing a flag indicating it is safe to start a REPL |
|
82 and a reason why it cannot. |
|
83 @rtype tuple of (bool, str) |
|
84 """ |
|
85 return True, "" |
|
86 |
|
87 def canStartPlotter(self): |
|
88 """ |
|
89 Public method to determine, if a Plotter can be started. |
|
90 |
|
91 @return tuple containing a flag indicating it is safe to start a |
|
92 Plotter and a reason why it cannot. |
|
93 @rtype tuple of (bool, str) |
|
94 """ |
|
95 return True, "" |
|
96 |
|
97 def canRunScript(self): |
|
98 """ |
|
99 Public method to determine, if a script can be executed. |
|
100 |
|
101 @return tuple containing a flag indicating it is safe to start a |
|
102 Plotter and a reason why it cannot. |
|
103 @rtype tuple of (bool, str) |
|
104 """ |
|
105 return True, "" |
|
106 |
|
107 def runScript(self, script): |
|
108 """ |
|
109 Public method to run the given Python script. |
|
110 |
|
111 @param script script to be executed |
|
112 @type str |
|
113 """ |
|
114 pythonScript = script.split("\n") |
|
115 self.sendCommands(pythonScript) |
|
116 |
|
117 def canStartFileManager(self): |
|
118 """ |
|
119 Public method to determine, if a File Manager can be started. |
|
120 |
|
121 @return tuple containing a flag indicating it is safe to start a |
|
122 File Manager and a reason why it cannot. |
|
123 @rtype tuple of (bool, str) |
|
124 """ |
|
125 return True, "" |
|
126 |
|
127 def hasTimeCommands(self): |
|
128 """ |
|
129 Public method to check, if the device supports time commands. |
|
130 |
|
131 The default returns True. |
|
132 |
|
133 @return flag indicating support for time commands |
|
134 @rtype bool |
|
135 """ |
|
136 return False |
|
137 |
|
138 def addDeviceMenuEntries(self, menu): |
|
139 """ |
|
140 Public method to add device specific entries to the given menu. |
|
141 |
|
142 @param menu reference to the context menu |
|
143 @type QMenu |
|
144 """ |
|
145 connected = self.microPython.isConnected() |
|
146 |
|
147 act = menu.addAction(self.tr("Flash MicroPython"), |
|
148 self.__flashMicroPython) |
|
149 act.setEnabled(not connected) |
|
150 act = menu.addAction(self.tr("Flash Firmware"), |
|
151 lambda: self.__flashMicroPython(firmware=True)) |
|
152 act.setEnabled(not connected) |
|
153 menu.addSeparator() |
|
154 act = menu.addAction(self.tr("Save Script"), self.__saveScriptToDevice) |
|
155 act.setToolTip(self.tr( |
|
156 "Save the current script to the selected device")) |
|
157 act.setEnabled(connected) |
|
158 act = menu.addAction(self.tr("Save Script as 'main.py'"), |
|
159 self.__saveMain) |
|
160 act.setToolTip(self.tr( |
|
161 "Save the current script as 'main.py' on the connected device")) |
|
162 act.setEnabled(connected) |
|
163 menu.addSeparator() |
|
164 act = menu.addAction(self.tr("Reset {0}").format(self.deviceName()), |
|
165 self.__resetDevice) |
|
166 act.setEnabled(connected) |
|
167 |
|
168 def hasFlashMenuEntry(self): |
|
169 """ |
|
170 Public method to check, if the device has its own flash menu entry. |
|
171 |
|
172 @return flag indicating a specific flash menu entry |
|
173 @rtype bool |
|
174 """ |
|
175 return True |
|
176 |
|
177 @pyqtSlot() |
|
178 def __flashMicroPython(self, firmware=False): |
|
179 """ |
|
180 Private slot to flash MicroPython or the DAPLink firmware to the |
|
181 device. |
|
182 |
|
183 @param firmware flag indicating to flash the DAPLink firmware |
|
184 @type bool |
|
185 """ |
|
186 # Attempts to find the path on the file system that represents the |
|
187 # plugged in micro:bit board. To flash the DAPLink firmware, it must be |
|
188 # in maintenance mode, for MicroPython in standard mode. |
|
189 if self.getDeviceType() == "bbc_microbit": |
|
190 # BBC micro:bit |
|
191 if firmware: |
|
192 deviceDirectories = Utilities.findVolume("MAINTENANCE", |
|
193 findAll=True) |
|
194 else: |
|
195 deviceDirectories = Utilities.findVolume("MICROBIT", |
|
196 findAll=True) |
|
197 else: |
|
198 # Calliope mini |
|
199 if firmware: |
|
200 deviceDirectories = Utilities.findVolume("MAINTENANCE", |
|
201 findAll=True) |
|
202 else: |
|
203 deviceDirectories = Utilities.findVolume("MINI", |
|
204 findAll=True) |
|
205 if len(deviceDirectories) == 0: |
|
206 if self.getDeviceType() == "bbc_microbit": |
|
207 # BBC micro:bit is not ready or not mounted |
|
208 if firmware: |
|
209 E5MessageBox.critical( |
|
210 self.microPython, |
|
211 self.tr("Flash MicroPython/Firmware"), |
|
212 self.tr( |
|
213 '<p>The BBC micro:bit is not ready for flashing' |
|
214 ' the DAPLink firmware. Follow these' |
|
215 ' instructions. </p>' |
|
216 '<ul>' |
|
217 '<li>unplug USB cable and any batteries</li>' |
|
218 '<li>keep RESET button pressed an plug USB cable' |
|
219 ' back in</li>' |
|
220 '<li>a drive called MAINTENANCE should be' |
|
221 ' available</li>' |
|
222 '</ul>' |
|
223 '<p>See the ' |
|
224 '<a href="https://microbit.org/guide/firmware/">' |
|
225 'micro:bit web site</a> for details.</p>' |
|
226 ) |
|
227 ) |
|
228 else: |
|
229 E5MessageBox.critical( |
|
230 self.microPython, |
|
231 self.tr("Flash MicroPython/Firmware"), |
|
232 self.tr( |
|
233 '<p>The BBC micro:bit is not ready for flashing' |
|
234 ' the MicroPython firmware. Please make sure,' |
|
235 ' that a drive called MICROBIT is available.' |
|
236 '</p>' |
|
237 ) |
|
238 ) |
|
239 else: |
|
240 # Calliope mini is not ready or not mounted |
|
241 if firmware: |
|
242 E5MessageBox.critical( |
|
243 self.microPython, |
|
244 self.tr("Flash MicroPython/Firmware"), |
|
245 self.tr( |
|
246 '<p>The "Calliope mini" is not ready for flashing' |
|
247 ' the DAPLink firmware. Follow these' |
|
248 ' instructions. </p>' |
|
249 '<ul>' |
|
250 '<li>unplug USB cable and any batteries</li>' |
|
251 '<li>keep RESET button pressed an plug USB cable' |
|
252 ' back in</li>' |
|
253 '<li>a drive called MAINTENANCE should be' |
|
254 ' available</li>' |
|
255 '</ul>' |
|
256 ) |
|
257 ) |
|
258 else: |
|
259 E5MessageBox.critical( |
|
260 self.microPython, |
|
261 self.tr("Flash MicroPython/Firmware"), |
|
262 self.tr( |
|
263 '<p>The "Calliope mini" is not ready for flashing' |
|
264 ' the MicroPython firmware. Please make sure,' |
|
265 ' that a drive called MINI is available.' |
|
266 '</p>' |
|
267 ) |
|
268 ) |
|
269 elif len(deviceDirectories) == 1: |
|
270 downloadsPath = QStandardPaths.standardLocations( |
|
271 QStandardPaths.StandardLocation.DownloadLocation)[0] |
|
272 firmware = E5FileDialog.getOpenFileName( |
|
273 self.microPython, |
|
274 self.tr("Flash MicroPython/Firmware"), |
|
275 downloadsPath, |
|
276 self.tr("MicroPython/Firmware Files (*.hex *.bin);;" |
|
277 "All Files (*)")) |
|
278 if firmware and os.path.exists(firmware): |
|
279 shutil.copy2(firmware, deviceDirectories[0]) |
|
280 else: |
|
281 E5MessageBox.warning( |
|
282 self, |
|
283 self.tr("Flash MicroPython/Firmware"), |
|
284 self.tr("There are multiple devices ready for flashing." |
|
285 " Please make sure, that only one device is prepared.") |
|
286 ) |
|
287 |
|
288 @pyqtSlot() |
|
289 def __saveMain(self): |
|
290 """ |
|
291 Private slot to copy the current script as 'main.py' onto the |
|
292 connected device. |
|
293 """ |
|
294 self.__saveScriptToDevice("main.py") |
|
295 |
|
296 @pyqtSlot() |
|
297 def __saveScriptToDevice(self, scriptName=""): |
|
298 """ |
|
299 Private method to save the current script onto the connected |
|
300 device. |
|
301 |
|
302 @param scriptName name of the file on the device |
|
303 @type str |
|
304 """ |
|
305 aw = e5App().getObject("ViewManager").activeWindow() |
|
306 if not aw: |
|
307 return |
|
308 |
|
309 title = ( |
|
310 self.tr("Save Script as '{0}'").format(scriptName) |
|
311 if scriptName else |
|
312 self.tr("Save Script") |
|
313 ) |
|
314 |
|
315 if not (aw.isPyFile() or aw.isMicroPythonFile()): |
|
316 yes = E5MessageBox.yesNo( |
|
317 self.microPython, |
|
318 title, |
|
319 self.tr("""The current editor does not contain a Python""" |
|
320 """ script. Write it anyway?""")) |
|
321 if not yes: |
|
322 return |
|
323 |
|
324 script = aw.text().strip() |
|
325 if not script: |
|
326 E5MessageBox.warning( |
|
327 self.microPython, |
|
328 title, |
|
329 self.tr("""The script is empty. Aborting.""")) |
|
330 return |
|
331 |
|
332 if not scriptName: |
|
333 scriptName = os.path.basename(aw.getFileName()) |
|
334 scriptName, ok = QInputDialog.getText( |
|
335 self.microPython, |
|
336 title, |
|
337 self.tr("Enter a file name on the device:"), |
|
338 QLineEdit.EchoMode.Normal, |
|
339 scriptName) |
|
340 if not ok or not bool(scriptName): |
|
341 return |
|
342 |
|
343 title = self.tr("Save Script as '{0}'").format(scriptName) |
|
344 |
|
345 commands = [ |
|
346 "fd = open('{0}', 'wb')".format(scriptName), |
|
347 "f = fd.write", |
|
348 ] |
|
349 for line in script.splitlines(): |
|
350 commands.append("f(" + repr(line + "\n") + ")") |
|
351 commands.append("fd.close()") |
|
352 out, err = self.microPython.commandsInterface().execute(commands) |
|
353 if err: |
|
354 E5MessageBox.critical( |
|
355 self.microPython, |
|
356 title, |
|
357 self.tr("""<p>The script could not be saved to the""" |
|
358 """ device.</p><p>Reason: {0}</p>""") |
|
359 .format(err.decode("utf-8"))) |
|
360 |
|
361 # reset the device |
|
362 self.__resetDevice() |
|
363 |
|
364 @pyqtSlot() |
|
365 def __resetDevice(self): |
|
366 """ |
|
367 Private slot to reset the connected device. |
|
368 """ |
|
369 if self.getDeviceType() == "bbc_microbit": |
|
370 # BBC micro:bit |
|
371 self.microPython.commandsInterface().execute([ |
|
372 "import microbit", |
|
373 "microbit.reset()", |
|
374 ]) |
|
375 else: |
|
376 # Calliope mini |
|
377 self.microPython.commandsInterface().execute([ |
|
378 "import calliope_mini", |
|
379 "calliope_mini.reset()", |
|
380 ]) |
|
381 |
|
382 def getDocumentationUrl(self): |
|
383 """ |
|
384 Public method to get the device documentation URL. |
|
385 |
|
386 @return documentation URL of the device |
|
387 @rtype str |
|
388 """ |
|
389 if self.getDeviceType() == "bbc_microbit": |
|
390 # BBC micro:bit |
|
391 return Preferences.getMicroPython("MicrobitDocuUrl") |
|
392 else: |
|
393 # Calliope mini |
|
394 return Preferences.getMicroPython("CalliopeDocuUrl") |
|
395 |
|
396 def getDownloadMenuEntries(self): |
|
397 """ |
|
398 Public method to retrieve the entries for the downloads menu. |
|
399 |
|
400 @return list of tuples with menu text and URL to be opened for each |
|
401 entry |
|
402 @rtype list of tuple of (str, str) |
|
403 """ |
|
404 if self.getDeviceType() == "bbc_microbit": |
|
405 return [ |
|
406 (self.tr("MicroPython Firmware for BBC micro:bit V1"), |
|
407 Preferences.getMicroPython("MicrobitMicroPythonUrl")), |
|
408 (self.tr("MicroPython Firmware for BBC micro:bit V2"), |
|
409 Preferences.getMicroPython("MicrobitV2MicroPythonUrl")), |
|
410 (self.tr("DAPLink Firmware"), |
|
411 Preferences.getMicroPython("MicrobitFirmwareUrl")) |
|
412 ] |
|
413 else: |
|
414 return [ |
|
415 (self.tr("MicroPython Firmware"), |
|
416 Preferences.getMicroPython("CalliopeMicroPythonUrl")), |
|
417 (self.tr("DAPLink Firmware"), |
|
418 Preferences.getMicroPython("CalliopeDAPLinkUrl")) |
|
419 ] |