|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2025 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing the device interface class for NRF52 boards with UF2 support. |
|
8 """ |
|
9 |
|
10 import ast |
|
11 import json |
|
12 |
|
13 from PyQt6.QtCore import QUrl, pyqtSlot |
|
14 from PyQt6.QtNetwork import QNetworkReply, QNetworkRequest |
|
15 from PyQt6.QtWidgets import QMenu |
|
16 |
|
17 from eric7 import EricUtilities, Preferences |
|
18 from eric7.EricWidgets import EricMessageBox |
|
19 from eric7.EricWidgets.EricApplication import ericApp |
|
20 |
|
21 from ..MicroPythonWidget import HAS_QTCHART |
|
22 from . import FirmwareGithubUrls |
|
23 from .DeviceBase import BaseDevice |
|
24 |
|
25 |
|
26 class Nrf52Device(BaseDevice): |
|
27 """ |
|
28 Class implementing the device for NRF52 boards with UF2 support. |
|
29 """ |
|
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 device type assigned to this device interface |
|
38 @type str |
|
39 @param parent reference to the parent object |
|
40 @type QObject |
|
41 """ |
|
42 super().__init__(microPythonWidget, deviceType, parent) |
|
43 |
|
44 self.__createNrfMenu() |
|
45 |
|
46 def setButtons(self): |
|
47 """ |
|
48 Public method to enable the supported action buttons. |
|
49 """ |
|
50 super().setButtons() |
|
51 |
|
52 self.microPython.setActionButtons( |
|
53 run=True, repl=True, files=True, chart=HAS_QTCHART |
|
54 ) |
|
55 |
|
56 def forceInterrupt(self): |
|
57 """ |
|
58 Public method to determine the need for an interrupt when opening the |
|
59 serial connection. |
|
60 |
|
61 @return flag indicating an interrupt is needed |
|
62 @rtype bool |
|
63 """ |
|
64 return False |
|
65 |
|
66 def deviceName(self): |
|
67 """ |
|
68 Public method to get the name of the device. |
|
69 |
|
70 @return name of the device |
|
71 @rtype str |
|
72 """ |
|
73 return self.tr("NRF52 with UF2") |
|
74 |
|
75 def canStartRepl(self): |
|
76 """ |
|
77 Public method to determine, if a REPL can be started. |
|
78 |
|
79 @return tuple containing a flag indicating it is safe to start a REPL |
|
80 and a reason why it cannot. |
|
81 @rtype tuple of (bool, str) |
|
82 """ |
|
83 return True, "" |
|
84 |
|
85 def canStartPlotter(self): |
|
86 """ |
|
87 Public method to determine, if a Plotter can be started. |
|
88 |
|
89 @return tuple containing a flag indicating it is safe to start a |
|
90 Plotter and a reason why it cannot. |
|
91 @rtype tuple of (bool, str) |
|
92 """ |
|
93 return True, "" |
|
94 |
|
95 def canRunScript(self): |
|
96 """ |
|
97 Public method to determine, if a script can be executed. |
|
98 |
|
99 @return tuple containing a flag indicating it is safe to start a |
|
100 Plotter and a reason why it cannot. |
|
101 @rtype tuple of (bool, str) |
|
102 """ |
|
103 return True, "" |
|
104 |
|
105 def runScript(self, script): |
|
106 """ |
|
107 Public method to run the given Python script. |
|
108 |
|
109 @param script script to be executed |
|
110 @type str |
|
111 """ |
|
112 pythonScript = script.split("\n") |
|
113 self.sendCommands(pythonScript) |
|
114 |
|
115 def canStartFileManager(self): |
|
116 """ |
|
117 Public method to determine, if a File Manager can be started. |
|
118 |
|
119 @return tuple containing a flag indicating it is safe to start a |
|
120 File Manager and a reason why it cannot. |
|
121 @rtype tuple of (bool, str) |
|
122 """ |
|
123 return True, "" |
|
124 |
|
125 def __createNrfMenu(self): |
|
126 """ |
|
127 Private method to create the NRF52 submenu. |
|
128 """ |
|
129 self.__nrfMenu = QMenu(self.tr("NRF52 Functions")) |
|
130 |
|
131 self.__showMpyAct = self.__nrfMenu.addAction( |
|
132 self.tr("Show MicroPython Versions"), self.__showFirmwareVersions |
|
133 ) |
|
134 self.__nrfMenu.addSeparator() |
|
135 self.__bootloaderAct = self.__nrfMenu.addAction( |
|
136 self.tr("Activate Bootloader"), self.__activateBootloader |
|
137 ) |
|
138 self.__flashMpyAct = self.__nrfMenu.addAction( |
|
139 self.tr("Flash MicroPython Firmware"), self.__flashPython |
|
140 ) |
|
141 self.__nrfMenu.addSeparator() |
|
142 self.__resetAct = self.__nrfMenu.addAction( |
|
143 self.tr("Reset Device"), self.__resetDevice |
|
144 ) |
|
145 |
|
146 def addDeviceMenuEntries(self, menu): |
|
147 """ |
|
148 Public method to add device specific entries to the given menu. |
|
149 |
|
150 @param menu reference to the context menu |
|
151 @type QMenu |
|
152 """ |
|
153 connected = self.microPython.isConnected() |
|
154 linkConnected = self.microPython.isLinkConnected() |
|
155 |
|
156 self.__showMpyAct.setEnabled(connected) |
|
157 self.__bootloaderAct.setEnabled(connected) |
|
158 self.__flashMpyAct.setEnabled(not linkConnected) |
|
159 self.__resetAct.setEnabled(connected) |
|
160 |
|
161 menu.addMenu(self.__nrfMenu) |
|
162 |
|
163 def hasFlashMenuEntry(self): |
|
164 """ |
|
165 Public method to check, if the device has its own flash menu entry. |
|
166 |
|
167 @return flag indicating a specific flash menu entry |
|
168 @rtype bool |
|
169 """ |
|
170 return True |
|
171 |
|
172 @pyqtSlot() |
|
173 def __flashPython(self): |
|
174 """ |
|
175 Private slot to flash a MicroPython firmware to the device. |
|
176 """ |
|
177 from ..UF2FlashDialog import UF2FlashDialog |
|
178 |
|
179 dlg = UF2FlashDialog(boardType="nrf52", parent=self.microPython) |
|
180 dlg.exec() |
|
181 |
|
182 @pyqtSlot() |
|
183 def __activateBootloader(self): |
|
184 """ |
|
185 Private slot to switch the board into 'bootloader' mode. |
|
186 """ |
|
187 if self.microPython.isConnected(): |
|
188 self.executeCommands( |
|
189 [ |
|
190 "import machine", |
|
191 "machine.bootloader()", |
|
192 ], |
|
193 mode=self._submitMode, |
|
194 ) |
|
195 # simulate pressing the disconnect button |
|
196 self.microPython.on_connectButton_clicked() |
|
197 |
|
198 @pyqtSlot() |
|
199 def __showFirmwareVersions(self): |
|
200 """ |
|
201 Private slot to show the firmware version of the connected device and the |
|
202 available firmware version. |
|
203 """ |
|
204 if self.microPython.isConnected(): |
|
205 if self._deviceData["mpy_name"] != "micropython": |
|
206 EricMessageBox.critical( |
|
207 self.microPython, |
|
208 self.tr("Show MicroPython Versions"), |
|
209 self.tr( |
|
210 """The firmware of the connected device cannot be""" |
|
211 """ determined or the board does not run MicroPython.""" |
|
212 """ Aborting...""" |
|
213 ), |
|
214 ) |
|
215 else: |
|
216 if self._deviceData["mpy_variant"] == "Pimoroni Pico": |
|
217 # MicroPython with Pimoroni add-on libraries |
|
218 url = QUrl(FirmwareGithubUrls["pimoroni_pico"]) |
|
219 else: |
|
220 url = QUrl(FirmwareGithubUrls["micropython"]) |
|
221 ui = ericApp().getObject("UserInterface") |
|
222 request = QNetworkRequest(url) |
|
223 reply = ui.networkAccessManager().head(request) |
|
224 reply.finished.connect(lambda: self.__firmwareVersionResponse(reply)) |
|
225 |
|
226 @pyqtSlot(QNetworkReply) |
|
227 def __firmwareVersionResponse(self, reply): |
|
228 """ |
|
229 Private slot handling the response of the latest version request. |
|
230 |
|
231 @param reply reference to the reply object |
|
232 @type QNetworkReply |
|
233 """ |
|
234 latestUrl = reply.url().toString() |
|
235 tag = latestUrl.rsplit("/", 1)[-1] |
|
236 while tag and not tag[0].isdecimal(): |
|
237 # get rid of leading non-decimal characters |
|
238 tag = tag[1:] |
|
239 latestVersion = EricUtilities.versionToTuple(tag) |
|
240 |
|
241 if self._deviceData["mpy_version"] == "unknown": |
|
242 currentVersionStr = self.tr("unknown") |
|
243 currentVersion = (0, 0, 0) |
|
244 else: |
|
245 currentVersionStr = ( |
|
246 self._deviceData["mpy_variant_version"] |
|
247 if bool(self._deviceData["mpy_variant_version"]) |
|
248 else self._deviceData["mpy_version"] |
|
249 ) |
|
250 currentVersion = EricUtilities.versionToTuple(currentVersionStr) |
|
251 |
|
252 msg = self.tr( |
|
253 "<h4>MicroPython Version Information</h4>" |
|
254 "<table>" |
|
255 "<tr><td>Installed:</td><td>{0}</td></tr>" |
|
256 "<tr><td>Available:</td><td>{1}</td></tr>" |
|
257 "{2}" |
|
258 "</table>" |
|
259 ).format( |
|
260 currentVersionStr, |
|
261 tag, |
|
262 ( |
|
263 self.tr("<tr><td>Variant:</td><td>{0}</td></tr>").format( |
|
264 self._deviceData["mpy_variant"] |
|
265 ) |
|
266 if self._deviceData["mpy_variant"] |
|
267 else "" |
|
268 ), |
|
269 ) |
|
270 if self._deviceData["mpy_variant"] in ["Pimoroni Pico"] and not bool( |
|
271 self._deviceData["mpy_variant_version"] |
|
272 ): |
|
273 # cannot derive update info |
|
274 msg += self.tr("<p>Update may be available.</p>") |
|
275 elif currentVersion < latestVersion: |
|
276 msg += self.tr("<p><b>Update available!</b></p>") |
|
277 |
|
278 EricMessageBox.information( |
|
279 self.microPython, |
|
280 self.tr("MicroPython Version"), |
|
281 msg, |
|
282 ) |
|
283 |
|
284 @pyqtSlot() |
|
285 def __resetDevice(self): |
|
286 """ |
|
287 Private slot to reset the connected device. |
|
288 """ |
|
289 if self.microPython.isConnected(): |
|
290 self.executeCommands( |
|
291 "import machine\nmachine.reset()\n", mode=self._submitMode |
|
292 ) |
|
293 |
|
294 def getDocumentationUrl(self): |
|
295 """ |
|
296 Public method to get the device documentation URL. |
|
297 |
|
298 @return documentation URL of the device |
|
299 @rtype str |
|
300 """ |
|
301 return Preferences.getMicroPython("MicroPythonDocuUrl") |
|
302 |
|
303 def getDownloadMenuEntries(self): |
|
304 """ |
|
305 Public method to retrieve the entries for the downloads menu. |
|
306 |
|
307 @return list of tuples with menu text and URL to be opened for each |
|
308 entry |
|
309 @rtype list of tuple of (str, str) |
|
310 """ |
|
311 return [ |
|
312 ( |
|
313 self.tr("MicroPython Firmware"), |
|
314 Preferences.getMicroPython("MicroPythonFirmwareUrl"), |
|
315 ), |
|
316 ("<separator>", ""), |
|
317 ( |
|
318 self.tr("CircuitPython Firmware"), |
|
319 Preferences.getMicroPython("CircuitPythonFirmwareUrl"), |
|
320 ), |
|
321 ( |
|
322 self.tr("CircuitPython Libraries"), |
|
323 Preferences.getMicroPython("CircuitPythonLibrariesUrl"), |
|
324 ), |
|
325 ] |
|
326 |
|
327 ################################################################## |
|
328 ## Methods below implement Bluetooth related methods |
|
329 ################################################################## |
|
330 |
|
331 def hasBluetooth(self): |
|
332 """ |
|
333 Public method to check the availability of Bluetooth. |
|
334 |
|
335 @return flag indicating the availability of Bluetooth |
|
336 @rtype bool |
|
337 @exception OSError raised to indicate an issue with the device |
|
338 """ |
|
339 command = """ |
|
340 def has_bt(): |
|
341 try: |
|
342 import ble |
|
343 if ble.address(): |
|
344 return True |
|
345 except (ImportError, OSError): |
|
346 pass |
|
347 |
|
348 return False |
|
349 |
|
350 print(has_bt()) |
|
351 del has_bt |
|
352 """ |
|
353 out, err = self.executeCommands(command, mode=self._submitMode, timeout=10000) |
|
354 if err: |
|
355 raise OSError(self._shortError(err)) |
|
356 return out.strip() == b"True" |
|
357 |
|
358 def getBluetoothStatus(self): |
|
359 """ |
|
360 Public method to get Bluetooth status data of the connected board. |
|
361 |
|
362 @return list of tuples containing the translated status data label and |
|
363 the associated value |
|
364 @rtype list of tuples of (str, str) |
|
365 @exception OSError raised to indicate an issue with the device |
|
366 """ |
|
367 command = """ |
|
368 def ble_status(): |
|
369 import ble |
|
370 import json |
|
371 |
|
372 res = { |
|
373 'active': bool(ble.enabled()), |
|
374 'mac': ble.address(), |
|
375 } |
|
376 |
|
377 print(json.dumps(res)) |
|
378 |
|
379 ble_status() |
|
380 del ble_status |
|
381 """ |
|
382 out, err = self.executeCommands(command, mode=self._submitMode) |
|
383 if err: |
|
384 raise OSError(self._shortError(err)) |
|
385 |
|
386 bleStatus = json.loads(out.decode("utf-8")) |
|
387 status = [ |
|
388 (self.tr("Active"), self.bool2str(bleStatus["active"])), |
|
389 (self.tr("MAC-Address"), bleStatus["mac"]), |
|
390 ] |
|
391 |
|
392 return status |
|
393 |
|
394 def activateBluetoothInterface(self): |
|
395 """ |
|
396 Public method to activate the Bluetooth interface. |
|
397 |
|
398 @return flag indicating the new state of the Bluetooth interface |
|
399 @rtype bool |
|
400 @exception OSError raised to indicate an issue with the device |
|
401 """ |
|
402 command = """ |
|
403 def activate_ble(): |
|
404 import ble |
|
405 |
|
406 if not ble.enabled(): |
|
407 ble.enable() |
|
408 print(bool(ble.enabled())) |
|
409 |
|
410 activate_ble() |
|
411 del activate_ble |
|
412 """ |
|
413 out, err = self.executeCommands(command, mode=self._submitMode) |
|
414 if err: |
|
415 raise OSError(self._shortError(err)) |
|
416 |
|
417 return out.strip() == b"True" |
|
418 |
|
419 def deactivateBluetoothInterface(self): |
|
420 """ |
|
421 Public method to deactivate the Bluetooth interface. |
|
422 |
|
423 @return flag indicating the new state of the Bluetooth interface |
|
424 @rtype bool |
|
425 @exception OSError raised to indicate an issue with the device |
|
426 """ |
|
427 command = """ |
|
428 def deactivate_ble(): |
|
429 import ble |
|
430 |
|
431 if ble.enabled(): |
|
432 ble.disable() |
|
433 print(bool(ble.enabled())) |
|
434 |
|
435 deactivate_ble() |
|
436 del deactivate_ble |
|
437 """ |
|
438 out, err = self.executeCommands(command, mode=self._submitMode) |
|
439 if err: |
|
440 raise OSError(self._shortError(err)) |
|
441 |
|
442 return out.strip() == b"True" |
|
443 |
|
444 def getDeviceScan(self, timeout=10): |
|
445 """ |
|
446 Public method to perform a Bluetooth device scan. |
|
447 |
|
448 @param timeout duration of the device scan in seconds (defaults |
|
449 to 10) |
|
450 @type int (optional) |
|
451 @return tuple containing a dictionary with the scan results and |
|
452 an error string |
|
453 @rtype tuple of (dict, str) |
|
454 """ |
|
455 from ..BluetoothDialogs.BluetoothAdvertisement import ( |
|
456 SCAN_RSP, |
|
457 BluetoothAdvertisement, |
|
458 ) |
|
459 |
|
460 command = """ |
|
461 def ble_scan(): |
|
462 import ble |
|
463 import ubluepy as ub |
|
464 |
|
465 ble_active = ble.enabled() |
|
466 if not ble_active: |
|
467 ble.enable() |
|
468 |
|
469 sc = ub.Scanner() |
|
470 scanResults = sc.scan({0} * 1000) |
|
471 for res in scanResults: |
|
472 try: |
|
473 scanData = res.getScanData() |
|
474 if res.addr(): |
|
475 for data in scanData: |
|
476 print({{ |
|
477 'address': res.addr(), |
|
478 'rssi': res.rssi(), |
|
479 'adv_type': data[0], |
|
480 'advertisement': bytes(data[2]), |
|
481 }}) |
|
482 except MemoryError: |
|
483 pass |
|
484 |
|
485 if not ble_active: |
|
486 ble.disable() |
|
487 |
|
488 ble_scan() |
|
489 del ble_scan |
|
490 """.format( |
|
491 timeout |
|
492 ) |
|
493 out, err = self.executeCommands( |
|
494 command, mode=self._submitMode, timeout=(timeout + 5) * 1000 |
|
495 ) |
|
496 if err: |
|
497 return {}, err |
|
498 |
|
499 scanResults = {} |
|
500 tempResults = {} |
|
501 |
|
502 for line in out.decode("utf-8").splitlines(): |
|
503 res = ast.literal_eval(line) |
|
504 address = res["address"] |
|
505 if address not in tempResults: |
|
506 tempResults[address] = { |
|
507 "advertisements": {}, |
|
508 } |
|
509 tempResults[address]["rssi"] = res["rssi"] |
|
510 tempResults[address]["advertisements"][res["adv_type"]] = res[ |
|
511 "advertisement" |
|
512 ] |
|
513 |
|
514 for address in tempResults: |
|
515 advertisements = bytearray() |
|
516 for advType, advertisement in tempResults[address][ |
|
517 "advertisements" |
|
518 ].items(): |
|
519 advertisements += ( |
|
520 (len(advertisement) + 1).to_bytes() |
|
521 + advType.to_bytes() |
|
522 + advertisement |
|
523 ) |
|
524 scanResults[address] = BluetoothAdvertisement(address) |
|
525 scanResults[address].update( |
|
526 SCAN_RSP, tempResults[address]["rssi"], advertisements |
|
527 ) |
|
528 |
|
529 return scanResults, "" |
|
530 |
|
531 def supportsDeviceScan(self): |
|
532 """ |
|
533 Public method to indicate, that the Bluetooth implementation supports |
|
534 scanning for devices. |
|
535 |
|
536 @return flag indicating that the scanning function is supported |
|
537 @rtype bool |
|
538 """ |
|
539 return True |
|
540 |
|
541 |
|
542 def createDevice(microPythonWidget, deviceType, _vid, _pid, _boardName, _serialNumber): |
|
543 """ |
|
544 Function to instantiate a MicroPython device object. |
|
545 |
|
546 @param microPythonWidget reference to the main MicroPython widget |
|
547 @type MicroPythonWidget |
|
548 @param deviceType device type assigned to this device interface |
|
549 @type str |
|
550 @param _vid vendor ID (unused) |
|
551 @type int |
|
552 @param _pid product ID (unused) |
|
553 @type int |
|
554 @param _boardName name of the board (unused) |
|
555 @type str |
|
556 @param _serialNumber serial number of the board (unused) |
|
557 @type str |
|
558 @return reference to the instantiated device object |
|
559 @rtype RP2Device |
|
560 """ |
|
561 return Nrf52Device(microPythonWidget, deviceType) |